// 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 { /// /// UI Toolkit minimap controller. Manages two stacked sub-elements (terrain + entities) /// inside a host , handles click-to-jump, drag-to-pan, /// right-click-to-move-builder, and scroll-wheel zoom against a /// and the local . Refreshes the entity overlay at a throttled /// rate so 500+ enemies don't dominate frame time. /// /// /// Coordinate spaces. Three are in play: /// /// World — XZ plane positions of game-side entities. Z+ is north. /// UI local — pixel coordinates within the minimap container. Y+ is DOWN. /// Texture — handled entirely by the baker; not exposed here. /// /// World↔UI conversion uses the currently-visible world rectangle, which is the full map /// at zoom 1 and a smaller rect centered on at zoom > 1. /// /// Zoom model. 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 . When zoom returns to 1 the visible rect snaps back to /// the full map. /// /// Terrain rendering. 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 overflow: hidden clips the off-view portion. The texture itself /// is baked once and never re-baked. /// public class MinimapView { // ----- Tuning ----------------------------------------------------- /// Entity overlay refresh rate. 20Hz handles 500+ enemies comfortably without /// burning the frame budget on Painter2D. Revisit if testing shows it's not enough. private const float EntityRepaintHz = 20f; private const float MinZoom = 1f; private const float MaxZoom = 4f; /// Multiplicative factor applied per scroll-wheel tick. 1.2 ≈ 6 ticks to span /// 1x → 4x, which feels neither sluggish nor twitchy in playtest. 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); // ----- 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(OnPointerDown); container.RegisterCallback(OnPointerMove); container.RegisterCallback(OnPointerUp); container.RegisterCallback(OnPointerCaptureOut); container.RegisterCallback(OnWheel); // Recompute terrain layout when the container resizes — the absolute pixel // positions we set on terrainLayer are container-relative. container.RegisterCallback(_ => { if (ready) ApplyView(); }); } // ----- Lifecycle -------------------------------------------------- /// /// Called from HUDController.Update. Performs lazy bake on the first frame /// after finishes loading, then triggers throttled entity /// overlay repaints. /// public void Tick() { if (!ready) { TryBake(); if (!ready) return; } float now = Time.unscaledTime; if (now - lastEntityRepaintTime >= 1f / EntityRepaintHz) { lastEntityRepaintTime = now; entityLayer.MarkDirtyRepaint(); } } /// Releases the baked texture. Call from HUDController.OnDestroy. 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 -------------------------------------------------- /// /// Recomputes / from the /// current and , clamps the view to stay /// within the map, and resizes/positions the terrain sub-element so its rendered /// region matches the visible world rect. /// 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) { var selection = SelectionState.Instance; if (selection == null || !selection.HasSelection) return; Vector3 worldTarget = UIToWorld(uiLocal); // Same RPC the world right-click uses; server validates and side-effects the queue. selection.SelectedBuilder.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); } /// 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. 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); } 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(); } } }