using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; /// /// MinimapEdgeIndicator - Shows arrows on the edge of the minimap UI /// pointing toward off-screen entities (allies, enemies, cash bags, objectives). /// /// Like games such as GTA, Fortnite, and PUBG - when entities are outside the /// minimap view, arrows appear at the border pointing in their direction. /// /// Attach to the minimap RawImage UI element, or let MinimapManager create it. /// public class MinimapEdgeIndicator : MonoBehaviour { [Header("Settings")] [SerializeField] private RectTransform minimapRect; [SerializeField] private float edgePadding = 15f; [SerializeField] private float indicatorSize = 60f; [SerializeField] private float updateInterval = 0.1f; [Header("Visibility")] [SerializeField] private bool showAllies = true; [SerializeField] private bool showEnemies = true; [SerializeField] private bool showCashBags = true; [SerializeField] private bool showVaults = true; [Header("Colors")] [SerializeField] private Color allyColor = new Color(0.2f, 0.8f, 0.2f, 1f); [SerializeField] private Color enemyColor = new Color(1f, 0.2f, 0.2f, 1f); [SerializeField] private Color cashBagColor = new Color(1f, 0.85f, 0f, 1f); [SerializeField] private Color playerVaultColor = new Color(0.2f, 0.9f, 0.2f, 1f); [SerializeField] private Color enemyVaultColor = new Color(0.9f, 0.2f, 0.2f, 1f); // Pool of indicator objects private List indicatorPool = new List(); private List indicatorImages = new List(); private int activeIndicatorCount = 0; private Camera minimapCamera; private Transform playerTransform; private string playerTeamTag = "Player"; private float lastUpdateTime; private Sprite arrowSprite; private float playerHeadingDeg; // Cached player Y rotation for GTA V style // Cached references for performance private CashSystemManager cachedCSM; private bool csmSearched = false; private void Start() { // Find minimap camera MinimapManager mm = FindObjectOfType(); if (mm != null) { minimapCamera = mm.GetComponent(); } // Auto-find minimap rect if not assigned if (minimapRect == null) { minimapRect = GetComponent(); // Try parent if this component doesn't have RectTransform if (minimapRect == null) { minimapRect = GetComponentInParent(); } } // Create arrow sprite once arrowSprite = CreateArrowSprite(); // Create initial pool for (int i = 0; i < 15; i++) { CreateIndicator(); } Debug.Log("[MinimapEdgeIndicator] Initialized - will show arrows for off-screen entities"); } private void CreateIndicator() { GameObject indicatorObj = new GameObject("EdgeIndicator"); indicatorObj.transform.SetParent(transform, false); RectTransform rect = indicatorObj.AddComponent(); rect.sizeDelta = new Vector2(indicatorSize, indicatorSize); rect.localScale = Vector3.one; Image img = indicatorObj.AddComponent(); img.sprite = arrowSprite; img.color = enemyColor; img.raycastTarget = false; // Add outline for better visibility Outline outline = indicatorObj.AddComponent(); outline.effectColor = new Color(0, 0, 0, 0.8f); outline.effectDistance = new Vector2(1, 1); indicatorObj.SetActive(false); indicatorPool.Add(rect); indicatorImages.Add(img); } private Sprite CreateArrowSprite() { int size = 48; // Higher resolution for sharper arrow Texture2D tex = new Texture2D(size, size); tex.filterMode = FilterMode.Bilinear; Color[] colors = new Color[size * size]; for (int i = 0; i < colors.Length; i++) colors[i] = Color.clear; Vector2 center = new Vector2(size / 2f, size / 2f); // Draw a clean filled arrow/chevron pointing right // (rotation applied at runtime to point toward off-screen targets) for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { float nx = (x - center.x) / (size / 2f); // -1 to 1 float ny = (y - center.y) / (size / 2f); // -1 to 1 // Filled arrow pointing right: tip at nx=0.8, base fans from nx=-0.4 // The arrow body fills from base to tip if (nx >= -0.4f && nx <= 0.8f) { float t = (nx + 0.4f) / 1.2f; // 0 at base, 1 at tip float allowedY = 0.6f * (1f - t); // Max width at base, 0 at tip if (Mathf.Abs(ny) <= allowedY) { // Anti-alias the edges float edgeDist = allowedY - Mathf.Abs(ny); float alpha = Mathf.Clamp01(edgeDist * 4f); colors[y * size + x] = new Color(1, 1, 1, alpha); } } } } tex.SetPixels(colors); tex.Apply(); return Sprite.Create(tex, new Rect(0, 0, size, size), new Vector2(0.5f, 0.5f)); } private void Update() { if (Time.time - lastUpdateTime < updateInterval) return; lastUpdateTime = Time.time; UpdateIndicators(); } private void UpdateIndicators() { // Find player FindPlayer(); if (playerTransform == null || minimapCamera == null || minimapRect == null) return; // Hide all first activeIndicatorCount = 0; for (int i = 0; i < indicatorPool.Count; i++) { indicatorPool[i].gameObject.SetActive(false); } Vector3 playerPos = playerTransform.position; float viewSize = minimapCamera.orthographicSize; // Cache player heading for GTA V rotating minimap playerHeadingDeg = playerTransform.eulerAngles.y; // Get minimap UI half-size Vector2 halfSize = minimapRect.rect.size / 2f; // Track all characters using static registry (zero-allocation, replaces FindObjectsOfType) if (showAllies || showEnemies) { foreach (var member in TeamMember.AllMembers) { if (member == null || member.transform == playerTransform) continue; bool isAlly = member.gameObject.CompareTag(playerTeamTag); if (isAlly && showAllies) { ShowIndicatorIfOffscreen(member.transform.position, playerPos, viewSize, halfSize, allyColor); } else if (!isAlly && showEnemies) { ShowIndicatorIfOffscreen(member.transform.position, playerPos, viewSize, halfSize, enemyColor); } } } // Cash bags if (showCashBags) { var cashBags = EntityRegistry.GetAll(); for (int i = 0; i < cashBags.Count; i++) { if (cashBags[i] == null) continue; ShowIndicatorIfOffscreen(cashBags[i].transform.position, playerPos, viewSize, halfSize, cashBagColor); } } // Vaults if (showVaults) { TeamVault[] vaults = FindObjectsOfType(); foreach (var vault in vaults) { if (vault == null) continue; Color vColor = vault.TeamTag == playerTeamTag ? playerVaultColor : enemyVaultColor; ShowIndicatorIfOffscreen(vault.transform.position, playerPos, viewSize, halfSize, vColor); } } } private void ShowIndicatorIfOffscreen(Vector3 worldPos, Vector3 playerPos, float viewSize, Vector2 halfSize, Color color) { // Calculate relative position in world space Vector3 relativePos = worldPos - playerPos; Vector2 relativePos2D = new Vector2(relativePos.x, relativePos.z); // GTA V STYLE: Rotate by negative player heading so edge positions // are in "minimap space" (which rotates with the player). // Without this, arrows point to wrong edges when the map is rotated. float headingRad = -playerHeadingDeg * Mathf.Deg2Rad; float cos = Mathf.Cos(headingRad); float sin = Mathf.Sin(headingRad); Vector2 rotated = new Vector2( relativePos2D.x * cos - relativePos2D.y * sin, relativePos2D.x * sin + relativePos2D.y * cos ); // Convert world distance to minimap UI space float worldToUIScale = halfSize.y / viewSize; Vector2 minimapPos = rotated * worldToUIScale; // Check if within minimap bounds bool isWithinBounds = Mathf.Abs(minimapPos.x) <= halfSize.x * 0.85f && Mathf.Abs(minimapPos.y) <= halfSize.y * 0.85f; if (isWithinBounds) return; // Already visible on minimap, don't show edge indicator // Calculate angle for arrow rotation (in rotated/minimap space) float angle = Mathf.Atan2(rotated.y, rotated.x) * Mathf.Rad2Deg; // Clamp to edge of minimap Vector2 normalizedDir = rotated.normalized; // Find intersection with minimap edge float maxX = halfSize.x - edgePadding; float maxY = halfSize.y - edgePadding; float tX = normalizedDir.x != 0 ? Mathf.Abs(maxX / normalizedDir.x) : float.MaxValue; float tY = normalizedDir.y != 0 ? Mathf.Abs(maxY / normalizedDir.y) : float.MaxValue; float t = Mathf.Min(tX, tY); Vector2 clampedPos = normalizedDir * t; // Get or create indicator if (activeIndicatorCount >= indicatorPool.Count) { CreateIndicator(); } RectTransform indicator = indicatorPool[activeIndicatorCount]; Image img = indicatorImages[activeIndicatorCount]; indicator.gameObject.SetActive(true); indicator.sizeDelta = new Vector2(indicatorSize, indicatorSize); // Force size every frame indicator.anchoredPosition = clampedPos; indicator.localRotation = Quaternion.Euler(0, 0, angle); img.color = color; activeIndicatorCount++; } private void FindPlayer() { // Cache CashSystemManager lookup if (!csmSearched) { cachedCSM = FindObjectOfType(); csmSearched = true; } // In Cash System mode, find controlled character if (cachedCSM != null && cachedCSM.CurrentControlledCharacter != null) { playerTransform = cachedCSM.CurrentControlledCharacter.transform; playerTeamTag = cachedCSM.CurrentControlledCharacter.tag; return; } // Fallback to Player tag if (playerTransform == null) { GameObject player = GameObject.FindGameObjectWithTag("Player"); if (player != null) { playerTransform = player.transform; playerTeamTag = "Player"; } } } /// /// Creates and attaches an edge indicator to a minimap UI element /// public static MinimapEdgeIndicator AttachTo(RectTransform minimapUI) { if (minimapUI == null) return null; MinimapEdgeIndicator existing = minimapUI.GetComponent(); if (existing != null) return existing; MinimapEdgeIndicator indicator = minimapUI.gameObject.AddComponent(); indicator.minimapRect = minimapUI; Debug.Log("[MinimapEdgeIndicator] Attached to " + minimapUI.name); return indicator; } }