UnityTowerDefense/Assets/_Project/Scripts/UI/Minimap/MinimapView.cs

548 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/UI/Minimap/MinimapView.cs
using UnityEngine;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
using TD.Levels;
namespace TD.UI.Minimap
{
/// <summary>
/// UI Toolkit minimap controller. Manages two stacked sub-elements (terrain + entities)
/// inside a host <see cref="VisualElement"/>, handles click-to-jump, drag-to-pan,
/// right-click-to-move-builder, and scroll-wheel zoom against a <see cref="CameraController"/>
/// and the local <see cref="SelectionState"/>. Refreshes the entity overlay at a throttled
/// rate so 500+ enemies don't dominate frame time.
/// </summary>
/// <remarks>
/// <para><b>Coordinate spaces.</b> Three are in play:
/// <list type="bullet">
/// <item><b>World</b> — XZ plane positions of game-side entities. Z+ is north.</item>
/// <item><b>UI local</b> — pixel coordinates within the minimap container. Y+ is DOWN.</item>
/// <item><b>Texture</b> — handled entirely by the baker; not exposed here.</item>
/// </list>
/// World↔UI conversion uses the currently-visible world rectangle, which is the full map
/// at zoom 1 and a smaller rect centered on <see cref="viewCenter"/> at zoom &gt; 1.</para>
///
/// <para><b>Zoom model.</b> Zoom is anchored to the cursor position: the world point under
/// the cursor before the scroll stays under the cursor after, so the user can dial in on a
/// region by hovering over it and scrolling. Zoom defaults to 1 (fully zoomed out) and is
/// clamped to <see cref="MaxZoom"/>. When zoom returns to 1 the visible rect snaps back to
/// the full map.</para>
///
/// <para><b>Terrain rendering.</b> The terrain sub-element is sized and positioned so the
/// currently-visible world rect maps to the container's contentRect. At zoom 1 the element
/// fills the container exactly; at higher zoom it grows (in pixels) past the container and
/// the container's <c>overflow: hidden</c> clips the off-view portion. The texture itself
/// is baked once and never re-baked.</para>
/// </remarks>
public class MinimapView
{
// ----- Tuning -----------------------------------------------------
/// <summary>Entity overlay refresh rate. 20Hz handles 500+ enemies comfortably without
/// burning the frame budget on Painter2D. Revisit if testing shows it's not enough.</summary>
private const float EntityRepaintHz = 20f;
private const float MinZoom = 1f;
private const float MaxZoom = 4f;
/// <summary>Multiplicative factor applied per scroll-wheel tick. 1.2 ≈ 6 ticks to span
/// 1x → 4x, which feels neither sluggish nor twitchy in playtest.</summary>
private const float ZoomStepFactor = 1.2f;
// Pixel-size floor for point-like icons (builders, enemies). Ensures they stay visible
// even at full zoom-out on a large map. Towers use their actual footprint instead and
// do not need a floor — they're always at least a couple of pixels by virtue of being
// 2×2 or larger.
private const float BuilderMinRadiusPx = 2.4f;
private const float EnemyMinRadiusPx = 1.5f;
// Outline added to builder icons so they read against same-color zone fill.
private static readonly Color BuilderOutline = new Color(1f, 1f, 1f, 0.85f);
// Viewport trapezoid (the "what the player sees" rectangle on the minimap).
// Drawn on top of all entities so it's always readable; matches WC3 visual style.
private static readonly Color ViewportColor = new Color(1f, 1f, 1f, 0.85f);
private const float ViewportLineWidth = 1.5f;
// Reused buffer for the camera's four world-space view corners. Heap-allocated
// once and refilled every repaint to avoid per-frame GC.
private readonly Vector3[] viewCornersBuf = new Vector3[4];
// ----- Refs -------------------------------------------------------
private readonly VisualElement container;
private readonly VisualElement terrainLayer;
private readonly VisualElement entityLayer;
private readonly CameraController cameraController;
// ----- Bake state -------------------------------------------------
// World-space rectangle the texture covers (the full map). Cached once at bake.
private Vector2 worldMin;
private Vector2 worldMax;
// Owned texture — kept so we can destroy it cleanly. Null until first successful bake.
private Texture2D bakedTerrain;
// True once terrain has been baked successfully.
private bool ready;
// ----- View state -------------------------------------------------
// Current zoom in [MinZoom, MaxZoom]. 1 = full map visible.
private float zoom = 1f;
// World-XZ point the visible rect is centered on. At zoom 1 this is forced to the
// world center; at higher zoom the user's scrolling determines it (clamped so the
// visible rect never leaves the map).
private Vector2 viewCenter;
// Visible world rect, derived from zoom + viewCenter in ApplyView().
private Vector2 visibleWorldMin;
private Vector2 visibleWorldMax;
// ----- Input state ------------------------------------------------
private bool isDragging;
private int dragPointerId;
// ----- Repaint throttle -------------------------------------------
private float lastEntityRepaintTime;
// ----- Construction -----------------------------------------------
public MinimapView(VisualElement container, CameraController cameraController)
{
this.container = container;
this.cameraController = cameraController;
// Two stacked sub-elements. Terrain is painted via background-image; the entity
// overlay uses generateVisualContent + Painter2D. Both are PickingMode.Ignore so
// pointer events bubble back to the container, where we capture them.
terrainLayer = new VisualElement { name = "minimap-terrain" };
terrainLayer.AddToClassList("minimap-terrain");
terrainLayer.pickingMode = PickingMode.Ignore;
container.Add(terrainLayer);
entityLayer = new VisualElement { name = "minimap-entities" };
entityLayer.AddToClassList("minimap-entities");
entityLayer.pickingMode = PickingMode.Ignore;
entityLayer.generateVisualContent += DrawEntities;
container.Add(entityLayer);
// Pointer events on the container drive click-to-jump, drag-to-pan, right-click-move.
container.RegisterCallback<PointerDownEvent>(OnPointerDown);
container.RegisterCallback<PointerMoveEvent>(OnPointerMove);
container.RegisterCallback<PointerUpEvent>(OnPointerUp);
container.RegisterCallback<PointerCaptureOutEvent>(OnPointerCaptureOut);
container.RegisterCallback<WheelEvent>(OnWheel);
// Recompute terrain layout when the container resizes — the absolute pixel
// positions we set on terrainLayer are container-relative.
container.RegisterCallback<GeometryChangedEvent>(_ => { if (ready) ApplyView(); });
}
// ----- Lifecycle --------------------------------------------------
/// <summary>
/// Called from <c>HUDController.Update</c>. Performs lazy bake on the first frame
/// after <see cref="LevelLoader"/> finishes loading, then triggers throttled entity
/// overlay repaints.
/// </summary>
public void Tick()
{
if (!ready)
{
TryBake();
if (!ready) return;
}
float now = Time.unscaledTime;
if (now - lastEntityRepaintTime >= 1f / EntityRepaintHz)
{
lastEntityRepaintTime = now;
entityLayer.MarkDirtyRepaint();
}
}
/// <summary>Releases the baked texture. Call from <c>HUDController.OnDestroy</c>.</summary>
public void Dispose()
{
if (bakedTerrain != null)
{
Object.Destroy(bakedTerrain);
bakedTerrain = null;
}
ready = false;
}
// ----- Bake -------------------------------------------------------
private void TryBake()
{
var loader = LevelLoader.Instance;
if (loader == null || !loader.IsLoaded) return;
bakedTerrain = MinimapTerrainBaker.Bake(loader);
if (bakedTerrain == null) return;
terrainLayer.style.backgroundImage =
new StyleBackground(Background.FromTexture2D(bakedTerrain));
// World extents of the baked rectangle. Tile (n) covers world n - 0.5 to n + 0.5.
var data = loader.LevelData;
float halfTile = GridCoordinates.TILE_SIZE * 0.5f;
float minX = data.GridOriginTile.x * GridCoordinates.TILE_SIZE - halfTile;
float maxX = (data.GridOriginTile.x + data.GridSize.x) * GridCoordinates.TILE_SIZE - halfTile;
float minZ = data.GridOriginTile.y * GridCoordinates.TILE_SIZE - halfTile;
float maxZ = (data.GridOriginTile.y + data.GridSize.y) * GridCoordinates.TILE_SIZE - halfTile;
worldMin = new Vector2(minX, minZ);
worldMax = new Vector2(maxX, maxZ);
// Default view: centered, fully zoomed out.
zoom = MinZoom;
viewCenter = (worldMin + worldMax) * 0.5f;
ApplyView();
ready = true;
}
// ----- View math --------------------------------------------------
/// <summary>
/// Recomputes <see cref="visibleWorldMin"/>/<see cref="visibleWorldMax"/> from the
/// current <see cref="zoom"/> and <see cref="viewCenter"/>, clamps the view to stay
/// within the map, and resizes/positions the terrain sub-element so its rendered
/// region matches the visible world rect.
/// </summary>
private void ApplyView()
{
var rect = container.contentRect;
if (rect.width <= 0f || rect.height <= 0f) return;
// Half-extents of the visible world rect.
float halfWorldX = (worldMax.x - worldMin.x) * 0.5f / zoom;
float halfWorldZ = (worldMax.y - worldMin.y) * 0.5f / zoom;
// Clamp viewCenter so the visible rect doesn't leave the map. At zoom 1 the
// clamp range collapses to a single point (the world center), forcing the view.
viewCenter.x = Mathf.Clamp(viewCenter.x,
worldMin.x + halfWorldX, worldMax.x - halfWorldX);
viewCenter.y = Mathf.Clamp(viewCenter.y,
worldMin.y + halfWorldZ, worldMax.y - halfWorldZ);
visibleWorldMin = new Vector2(viewCenter.x - halfWorldX, viewCenter.y - halfWorldZ);
visibleWorldMax = new Vector2(viewCenter.x + halfWorldX, viewCenter.y + halfWorldZ);
// Position the terrain element so visibleWorldMin maps to container (0, 0) — in
// UI coords where y is top-down and the texture is flipped to put north at top.
// Terrain at zoom z is rect.size * z big, and we offset it so the visible window
// lands inside the container.
float worldRangeX = worldMax.x - worldMin.x;
float worldRangeZ = worldMax.y - worldMin.y;
float terrainW = rect.width * zoom;
float terrainH = rect.height * zoom;
float left = -(visibleWorldMin.x - worldMin.x) / worldRangeX * terrainW;
// Top: container's top corresponds to visibleWorldMax.z. Distance from texture's
// top (worldMax.z) to visibleWorldMax.z, as a fraction of world range, times height.
float top = -(worldMax.y - visibleWorldMax.y) / worldRangeZ * terrainH;
terrainLayer.style.width = terrainW;
terrainLayer.style.height = terrainH;
terrainLayer.style.left = left;
terrainLayer.style.top = top;
terrainLayer.style.right = StyleKeyword.Auto;
terrainLayer.style.bottom = StyleKeyword.Auto;
// Entity overlay needs a redraw to reflect the new visible rect.
entityLayer.MarkDirtyRepaint();
}
// ----- Pointer handling -------------------------------------------
private void OnPointerDown(PointerDownEvent evt)
{
if (!ready) return;
if (evt.button == 0)
{
// Left button: jump camera + start drag-to-pan.
if (cameraController == null) return;
isDragging = true;
dragPointerId = evt.pointerId;
container.CapturePointer(evt.pointerId);
cameraController.BeginDrag();
cameraController.JumpTo(UIToWorld(evt.localPosition));
evt.StopPropagation();
}
else if (evt.button == 1)
{
// Right button: move-and-pause command on the selected builder. Fires
// immediately; no drag/capture semantics (single-shot like an RTS).
HandleRightClickMove(evt.localPosition);
evt.StopPropagation();
}
}
private void OnPointerMove(PointerMoveEvent evt)
{
if (!isDragging || evt.pointerId != dragPointerId) return;
cameraController.JumpTo(UIToWorld(evt.localPosition));
evt.StopPropagation();
}
private void OnPointerUp(PointerUpEvent evt)
{
if (!isDragging || evt.pointerId != dragPointerId) return;
EndDragging(evt.pointerId);
evt.StopPropagation();
}
// Lost capture — end drag cleanly so we don't leave CameraController stuck.
private void OnPointerCaptureOut(PointerCaptureOutEvent evt)
{
if (!isDragging || evt.pointerId != dragPointerId) return;
EndDragging(evt.pointerId);
}
private void EndDragging(int pointerId)
{
isDragging = false;
cameraController?.EndDrag();
if (container.HasPointerCapture(pointerId))
container.ReleasePointer(pointerId);
}
private void HandleRightClickMove(Vector2 uiLocal)
{
// Right-click on the minimap = "send my builder there". Only meaningful when
// the LOCAL BUILDER is selected; a tower or enemy selection has no move action.
var selection = SelectionState.Instance;
var builder = selection?.SelectedBuilder;
if (builder == null) return;
Vector3 worldTarget = UIToWorld(uiLocal);
// Same RPC the world right-click uses; server validates and side-effects the queue.
builder.RequestMoveAndPauseRpc(worldTarget);
}
// ----- Zoom -------------------------------------------------------
private void OnWheel(WheelEvent evt)
{
if (!ready) return;
// Cursor-anchored zoom: capture the world point under the cursor before the
// zoom change, then move viewCenter so that same world point lands at the same
// UI position afterward. Result: the cursor "drills in" on whatever it's over.
Vector3 cursorWorldBefore = UIToWorld(evt.localMousePosition);
// WheelEvent.delta.y: positive = scroll down (typically zoom out), negative = up.
// Multiplicative steps feel natural and keep the rate consistent across zoom levels.
float steps = -evt.delta.y;
float newZoom = Mathf.Clamp(zoom * Mathf.Pow(ZoomStepFactor, steps), MinZoom, MaxZoom);
if (Mathf.Approximately(newZoom, zoom))
{
evt.StopPropagation();
return;
}
zoom = newZoom;
// Compute viewCenter that keeps cursorWorldBefore under the cursor at the new zoom.
// visibleWorldMin = viewCenter - halfWorld; visibleWorldMax = viewCenter + halfWorld
// UIToWorld(uiLocal) = visibleWorldMin + frac * (visibleWorldMax - visibleWorldMin)
// = viewCenter - halfWorld + frac * 2 * halfWorld
// = viewCenter + (2*frac - 1) * halfWorld
// Solve viewCenter from desired cursorWorldBefore at the cursor's frac.
var rect = container.contentRect;
float fx = Mathf.Clamp01(evt.localMousePosition.x / rect.width);
float fyTopDown = Mathf.Clamp01(evt.localMousePosition.y / rect.height);
float fzBottomUp = 1f - fyTopDown;
float halfWorldX = (worldMax.x - worldMin.x) * 0.5f / zoom;
float halfWorldZ = (worldMax.y - worldMin.y) * 0.5f / zoom;
viewCenter.x = cursorWorldBefore.x - (2f * fx - 1f) * halfWorldX;
viewCenter.y = cursorWorldBefore.z - (2f * fzBottomUp - 1f) * halfWorldZ;
ApplyView(); // clamps viewCenter and updates terrain element
evt.StopPropagation();
}
// ----- Coordinate transforms --------------------------------------
// UI local (y down) → world (z up). Clamps to visible map rect so dragging past the
// edge doesn't fly the camera off the map.
private Vector3 UIToWorld(Vector2 uiLocal)
{
var rect = container.contentRect;
if (rect.width <= 0f || rect.height <= 0f) return Vector3.zero;
float fx = Mathf.Clamp01(uiLocal.x / rect.width);
float fyTopDown = Mathf.Clamp01(uiLocal.y / rect.height);
float fzBottomUp = 1f - fyTopDown;
float worldX = Mathf.Lerp(visibleWorldMin.x, visibleWorldMax.x, fx);
float worldZ = Mathf.Lerp(visibleWorldMin.y, visibleWorldMax.y, fzBottomUp);
return new Vector3(worldX, GridCoordinates.BUILDABLE_PLANE_Y, worldZ);
}
// World → UI local. Used for placing entity icons.
//
// IMPORTANT: this must NOT clamp. Mathf.InverseLerp clamps to [0,1], which would
// pin off-screen entities to the minimap edge — making zoomed-in views show
// ghost icons stuck against every border. We compute the fraction manually so
// out-of-view entities get UI coords outside the container's rect, where the
// bounds check in DrawOneEntity culls them.
private Vector2 WorldToUI(Vector3 world)
{
var rect = container.contentRect;
float rangeX = visibleWorldMax.x - visibleWorldMin.x;
float rangeZ = visibleWorldMax.y - visibleWorldMin.y;
if (rangeX <= 0.0001f || rangeZ <= 0.0001f) return Vector2.zero;
float fx = (world.x - visibleWorldMin.x) / rangeX;
float fzBottomUp = (world.z - visibleWorldMin.y) / rangeZ;
float fyTopDown = 1f - fzBottomUp;
return new Vector2(fx * rect.width, fyTopDown * rect.height);
}
/// <summary>Pixels per world unit at the current zoom. Used to scale entity icons so
/// e.g. a 2×2 tower footprint reads as a 2-tile square on the minimap.</summary>
private float PixelsPerWorldUnit
{
get
{
var rect = container.contentRect;
float visibleWorldWidth = visibleWorldMax.x - visibleWorldMin.x;
return visibleWorldWidth > 0.0001f ? rect.width / visibleWorldWidth : 1f;
}
}
// ----- Entity overlay drawing -------------------------------------
private void DrawEntities(MeshGenerationContext mgc)
{
if (!ready) return;
var painter = mgc.painter2D;
float pxPerWorld = PixelsPerWorldUnit;
// The local player's builder always draws on top. Cache the reference once
// (Builder.Local does a dictionary lookup) and cast to the interface so the
// ReferenceEquals compare against registry entries works without boxing.
IMinimapEntity localBuilder = Builder.Local;
// Pass 1: every entity except the local builder. Order within this pass is
// arbitrary (registry is a HashSet); revisit if cross-entity layering between
// non-local entities ever matters.
MinimapEntityRegistry.ForEach(e =>
{
if (ReferenceEquals(e, localBuilder)) return;
DrawOneEntity(painter, e, pxPerWorld);
});
// Pass 2: local builder on top. Skipped if the local builder isn't spawned
// (e.g., on a dedicated server or before the local client's builder arrives).
if (localBuilder != null)
DrawOneEntity(painter, localBuilder, pxPerWorld);
// Pass 3: the camera-viewport trapezoid sits on top of everything so the
// player can always see where they're looking, regardless of zone tint or
// unit density underneath.
DrawViewportRect(painter);
}
// Draws a thin white outline matching the camera's footprint on the buildable
// plane. Because the camera is angled, the on-plane footprint is a TRAPEZOID
// (far edge wider than near edge) — that's the visual we want, since it tells
// the player how much of the world they're actually seeing at the current pitch.
private void DrawViewportRect(Painter2D painter)
{
if (cameraController == null) return;
// Even when one or more corners can't be projected onto the plane (camera at
// the horizon), the fallback puts them at a far point along the ray. The
// resulting UI coords land outside the container and get clipped by
// overflow:hidden — perfectly acceptable visual.
cameraController.TryGetViewportWorldCorners(viewCornersBuf);
Vector2 a = WorldToUI(viewCornersBuf[0]);
Vector2 b = WorldToUI(viewCornersBuf[1]);
Vector2 c = WorldToUI(viewCornersBuf[2]);
Vector2 d = WorldToUI(viewCornersBuf[3]);
painter.strokeColor = ViewportColor;
painter.lineWidth = ViewportLineWidth;
painter.BeginPath();
painter.MoveTo(a);
painter.LineTo(b);
painter.LineTo(c);
painter.LineTo(d);
painter.ClosePath();
painter.Stroke();
}
private void DrawOneEntity(Painter2D p, IMinimapEntity entity, float pxPerWorld)
{
if (entity == null) return;
Vector2 ui = WorldToUI(entity.WorldPosition);
// Skip if obviously off-screen — overflow:hidden clips it anyway, but skipping
// saves Painter2D work for entities far outside the zoomed-in window.
var rect = container.contentRect;
if (ui.x < -8f || ui.y < -8f || ui.x > rect.width + 8f || ui.y > rect.height + 8f) return;
Color col = entity.MinimapColor; col.a = 1f;
p.fillColor = col;
switch (entity.IconKind)
{
case MinimapIconKind.Enemy:
{
float r = Mathf.Max(entity.MinimapWorldSize * 0.5f * pxPerWorld, EnemyMinRadiusPx);
DrawCircle(p, ui, r);
break;
}
case MinimapIconKind.Tower:
{
// Tower size follows footprint exactly — no pixel floor. Adjacent towers
// visually touch on the minimap because their drawn squares span their
// full footprint extent in world units.
float halfPx = entity.MinimapWorldSize * 0.5f * pxPerWorld;
DrawSquare(p, ui, halfPx);
break;
}
case MinimapIconKind.Builder:
{
float r = Mathf.Max(entity.MinimapWorldSize * 0.5f * pxPerWorld, BuilderMinRadiusPx);
DrawCircle(p, ui, r);
p.strokeColor = BuilderOutline;
p.lineWidth = 1f;
p.Stroke();
break;
}
}
}
private static void DrawCircle(Painter2D p, Vector2 center, float radius)
{
p.BeginPath();
p.Arc(center, radius, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree));
p.Fill();
}
private static void DrawSquare(Painter2D p, Vector2 center, float halfSize)
{
p.BeginPath();
p.MoveTo(new Vector2(center.x - halfSize, center.y - halfSize));
p.LineTo(new Vector2(center.x + halfSize, center.y - halfSize));
p.LineTo(new Vector2(center.x + halfSize, center.y + halfSize));
p.LineTo(new Vector2(center.x - halfSize, center.y + halfSize));
p.ClosePath();
p.Fill();
}
}
}