using GeoSus.Client; using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using TMPro; using UnityEngine; using UnityEngine.UI; namespace Subsystems{ [System.Serializable] public class BuildingSettings { public Material ResidentialBuildingsMat; public float ResidentialBuildingHeight; public Material CommercialBuildingsMat; public float CommercialBuildingHeight; public Material IndustrialBuildingsMat; public float IndustrialBuildingHeight; public Material DefaultBuildingMat; public float DefaultBuildingHeight; } [System.Serializable] public class PathwaySettings { public Material FootwayMat; public float FootwayWidth; public Material PathMat; public float PathWidth; public Material StepsMat; public float StepsWidth; public Material CyclewayMat; public float CyclewayWidth; public Material PedestrianMat; public float PedestrianWidth; public Material RoadMat; public float RoadWidth; public Material ServiceMat; public float ServiceWidth; public Material ResidentialMat; public float ResidentialWidth; public Material TrackMat; public float TrackWidth; public Material DefaultMat; public float DefaultWidth; } [System.Serializable] public class AreaSettings { public Material ParkMat; public Material GardenMat; public Material PlaygroundMat; public Material ForestMat; public Material GrassMat; public Material WaterMat; public Material DefaultMat; } public class GameManager_Map { private GameClient _gameClient; private GameObject _mapCenterPoint; private Position _centerPosition; private BuildingSettings _buildingSettings; private PathwaySettings _pathwaySettings; private AreaSettings _areaSettings; private const float _metersPerUnit = 1f; // ── Layer Y separation (single source of truth for vertical stacking) ─── // Areas at the bottom, paths above areas, buildings extruded upward from // their own base, POIs floating well above everything else. Z-fighting // happens when adjacent geometry shares a Y; these constants keep each // logical layer at a distinct elevation. private const float kAreaBaseY = 0.10f; private const float kPathY = 0.30f; private const float kBuildingBaseY = 0.50f; private const float kPoiY = 2.00f; // Render-queue forcing was tried in P3 to disambiguate same-Y geometry // but turned out to be the cause of the "blank map in mobile game view, // fine in scene view" regression: forcing transparent-class shaders // (default queue 3000+) into the Geometry range (2000-2150) breaks // their depth-write/blend assumptions on mobile shader paths. The // editor's scene view masks it because it uses different render paths // and post-process is off there. Queue forcing removed in P8; // disambiguation is now via Y-layering + per-area Y-stagger alone, // which the depth buffer resolves correctly even on weak mobile GPUs. // ── Marker sizing (top-down camera, units = meters) ───────────────── // The camera's orthographic size pushes "1 meter" to a small fraction // of the screen. Markers need to be visibly larger than buildings' // footprints for instant recognition. private const float kMarkerHeight = 8f; // pillar height private const float kMarkerRadius = 3f; // pillar radius (cylinder X/Z) private const float kMarkerY = 4f; // base Y so pillar centers ~mid-height private const float kLabelY = 9f; // text label sits above pillar top private const float kLabelFontSize = 14f; // 3D text size in world units // Runtime marker collections private Dictionary _taskMarkers = new Dictionary(); private Dictionary _bodyMarkers = new Dictionary(); private Dictionary _playerAvatars = new Dictionary(); private List _sabotageMarkers = new List(); public GameManager_Map(GameClient gameClient, GameObject mapCenterPoint, BuildingSettings buildingSettings, PathwaySettings pathwaySettings, AreaSettings areaSettings) { _gameClient = gameClient; _mapCenterPoint = mapCenterPoint; _buildingSettings = buildingSettings; _pathwaySettings = pathwaySettings; _areaSettings = areaSettings; } public bool IsSceneReady => _mapCenterPoint != null; /// Called from OnSceneLoaded when Client.unity is loaded so the /// MapCenterPoint (which lives in Client.unity) can be wired at runtime. public void SetMapCenterPoint(GameObject go) { _mapCenterPoint = go; } public void BuildMap() { if (_mapCenterPoint == null) { Debug.LogWarning("[Map] BuildMap skipped: MapCenterPoint is not yet bound."); return; } if (_gameClient?.CurrentLobbyState?.MapData == null) { Debug.LogWarning("[Map] BuildMap skipped: no MapData in CurrentLobbyState."); return; } ClearChildren(); _centerPosition = _gameClient.CurrentLobbyState.MapData.Center; GameObject buildingsRoot = new GameObject("Buildings"); buildingsRoot.transform.parent = _mapCenterPoint.transform; GameObject pathRoot = new GameObject("Pathways"); pathRoot.transform.parent = _mapCenterPoint.transform; GameObject areaRoot = new GameObject("Areas"); areaRoot.transform.parent = _mapCenterPoint.transform; foreach (var building in _gameClient.CurrentLobbyState.MapData.GetBuildings()) { string buildingType = "Unknown"; try { buildingType = _gameClient.CurrentLobbyState.MapData.BuildingTypes[_gameClient.CurrentLobbyState.MapData.GetBuildings().IndexOf(building)]; } catch (Exception ex) { Debug.Log($"Error: {ex.Message}"); } building.Name = buildingType; GameObject b = BuildBuildingMesh(building); b.transform.parent = buildingsRoot.transform; } foreach (var path in _gameClient.CurrentLobbyState.MapData.GetPathways()) { GameObject p = BuildPathwayMesh(path); p.transform.parent = pathRoot.transform; } foreach (var area in _gameClient.CurrentLobbyState.MapData.GetAreas()) { GameObject a = BuildAreaMesh(area); a.transform.parent = areaRoot.transform; } GameObject poiRoot = new GameObject("POIs"); poiRoot.transform.parent = _mapCenterPoint.transform; int poiCount = 0; foreach (var poi in _gameClient.CurrentLobbyState.MapData.GetPOIs()) { GameObject p = BuildPOIMarker(poi); if (p != null) { p.transform.parent = poiRoot.transform; poiCount++; } } // Diagnostic - if the user reports "map missing in game view" but // the counts here are non-zero, the bug is camera/culling related, // not a build issue. int buildings = _gameClient.CurrentLobbyState.MapData.GetBuildings()?.Count ?? 0; int paths = _gameClient.CurrentLobbyState.MapData.GetPathways()?.Count ?? 0; int areas = _gameClient.CurrentLobbyState.MapData.GetAreas()?.Count ?? 0; Debug.Log($"[Map] BuildMap done: {buildings} buildings, {paths} paths, " + $"{areas} areas, {poiCount} POIs. MapCenterPoint={_mapCenterPoint.name} " + $"layer={_mapCenterPoint.layer} pos={_mapCenterPoint.transform.position} " + $"scale={_mapCenterPoint.transform.localScale}"); } /// /// Build a tall, brightly-colored pillar for a Point of Interest with /// a 3D text label above it (e.g. "FOOD", "SHOP"). The label is laid /// flat on the XZ plane facing UP so it reads correctly under the /// orthogonal top-down camera. /// private GameObject BuildPOIMarker(MapPOI poi) { if (poi == null) return null; var color = ColorForPOI(poi.POIType); string label = LabelForPOI(poi.POIType); var pos = poi.Location.ToLocalVector3(_centerPosition); return CreateMarkerWithLabel($"POI_{poi.POIType}_{poi.Id}", pos, color, label); } /// /// Shared marker builder: tall colored cylinder pillar + 3D text label /// above it. Used by POIs, tasks, bodies, and sabotage stations so /// they all share a visual language ("colored pillar with a name"). /// private GameObject CreateMarkerWithLabel(string name, Vector3 worldPos, Color color, string label) { var go = GameObject.CreatePrimitive(PrimitiveType.Cylinder); go.name = name; // Strip the auto-added collider - markers are visual only. var col = go.GetComponent(); if (col != null) UnityEngine.Object.Destroy(col); go.transform.position = worldPos + Vector3.up * kMarkerY; // Cylinder's default unit is 2 tall, 1 wide. Scale Y by half of // kMarkerHeight (built-in is 2 units), X/Z by kMarkerRadius. go.transform.localScale = new Vector3(kMarkerRadius, kMarkerHeight * 0.5f, kMarkerRadius); var mr = go.GetComponent(); if (mr != null) { // One .material access -> single clone of the primitive's // default mat. Don't touch renderQueue (P3 regression cause). var inst = mr.material; if (inst != null) inst.color = color; } // 3D text label - lays flat on top of the pillar facing up. // Parented to the marker so it follows position changes. var labelGO = new GameObject("Label"); labelGO.transform.SetParent(go.transform, worldPositionStays: false); // Local Y offset: pillar's local scale Y is kMarkerHeight/2, but // the cylinder primitive is 2 units tall in local space, so its // top is at local +1. Label sits a hair above that. labelGO.transform.localPosition = new Vector3(0, 1.05f, 0); // Rotate 90 around X so the text quad's normal points +Y (toward // the top-down camera). The default TMP forward is +Z. labelGO.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); // Compensate for the cylinder's non-uniform parent scale so the // text size in world units matches kLabelFontSize regardless of // how the pillar was scaled. labelGO.transform.localScale = new Vector3( 1f / kMarkerRadius, 1f / (kMarkerHeight * 0.5f), 1f / kMarkerRadius); var tmp = labelGO.AddComponent(); tmp.text = label; tmp.fontSize = kLabelFontSize; tmp.color = Color.white; tmp.fontStyle = FontStyles.Bold; tmp.alignment = TextAlignmentOptions.Center; tmp.outlineColor = Color.black; tmp.outlineWidth = 0.25f; // Reasonable bounds so the text mesh isn't auto-clipped. var rt = tmp.rectTransform; rt.sizeDelta = new Vector2(20, 4); return go; } private static Color ColorForPOI(MapPOIType type) { switch (type) { case MapPOIType.FoodDrink: return new Color(1.00f, 0.55f, 0.00f); // orange case MapPOIType.Shop: return new Color(0.20f, 0.60f, 1.00f); // blue case MapPOIType.Health: return new Color(0.96f, 0.27f, 0.27f); // red case MapPOIType.Transport: return new Color(0.85f, 0.85f, 0.20f); // yellow case MapPOIType.Culture: return new Color(0.65f, 0.30f, 0.95f); // purple case MapPOIType.Landmark: return new Color(0.95f, 0.85f, 0.40f); // gold case MapPOIType.Recreation: return new Color(0.30f, 0.85f, 0.30f); // green default: return new Color(0.75f, 0.75f, 0.80f); // muted grey } } private static string LabelForPOI(MapPOIType type) { switch (type) { case MapPOIType.FoodDrink: return "FOOD"; case MapPOIType.Shop: return "SHOP"; case MapPOIType.Health: return "HEALTH"; case MapPOIType.Transport: return "TRANSIT"; case MapPOIType.Culture: return "CULTURE"; case MapPOIType.Landmark: return "LANDMARK"; case MapPOIType.Recreation: return "PARK"; default: return "POI"; } } void ClearChildren() { List toDestroy = new List(); foreach (Transform t in _mapCenterPoint.transform) toDestroy.Add(t.gameObject); foreach (var g in toDestroy) { UnityEngine.Object.DestroyImmediate(g); } } #region Mesh Building GameObject BuildBuildingMesh(MapBuilding b) { var building = new GameObject($"Building_{b.Name ?? "Unknown"}"); // Výpočet středu budovy. Lift the base above kPathY so building // walls visibly extrude *upward* from above the road/area layer // instead of starting at ground (which made them clip into paved // areas that share their footprint). Vector3 center = CalculatePolygonCenter(b.Outline); building.transform.position = center + Vector3.up * kBuildingBaseY; // Vytvoření mesh pro budovu MeshFilter meshFilter = building.AddComponent(); MeshRenderer meshRenderer = building.AddComponent(); float height; Material mat; switch (b.BuildingType.ToLower()) { case "residential": mat = _buildingSettings.ResidentialBuildingsMat; height = _buildingSettings.ResidentialBuildingHeight; break; case "commercial": mat = _buildingSettings.CommercialBuildingsMat; height = _buildingSettings.CommercialBuildingHeight; break; case "industrial": mat = _buildingSettings.IndustrialBuildingsMat; height = _buildingSettings.IndustrialBuildingHeight; break; default: mat = _buildingSettings.DefaultBuildingMat; height = _buildingSettings.DefaultBuildingHeight; break; } Mesh mesh = CreateExtrudedPolygonMesh(b.Outline, height); meshFilter.mesh = mesh; //TODO: material by type // Použijeme barvu podle typu budovy. Use sharedMaterial to keep // the project's Material asset reference - no clone, no leak. // Y-position alone disambiguates building geometry from area/path // layers; we don't need renderQueue overrides (which broke mobile // rendering for transparent-class shaders in P3). meshRenderer.sharedMaterial = mat; // Přidání collideru pro interakci building.AddComponent(); return building; } GameObject BuildPathwayMesh(MapPathway w) { var path = new GameObject($"Path_{w.Name ?? "Unknown"}"); // Použijeme LineRenderer pro jednoduchost LineRenderer line = path.AddComponent(); float width; Material mat; switch (w.PathType) { case PathType.Footway: mat = _pathwaySettings.FootwayMat; width = _pathwaySettings.FootwayWidth; break; case PathType.Path: mat = _pathwaySettings.PathMat; width = _pathwaySettings.PathWidth; break; case PathType.Steps: mat = _pathwaySettings.StepsMat; width = _pathwaySettings.PathWidth; break; case PathType.Cycleway: mat = _pathwaySettings.CyclewayMat; width = _pathwaySettings.CyclewayWidth; break; case PathType.Pedestrian: mat = _pathwaySettings.PedestrianMat; width = _pathwaySettings.PedestrianWidth; break; case PathType.Road: mat = _pathwaySettings.RoadMat; width = _pathwaySettings.RoadWidth; break; case PathType.Service: mat = _pathwaySettings.ServiceMat; width = _pathwaySettings.ServiceWidth; break; case PathType.Residential: mat = _pathwaySettings.ResidentialMat; width = _pathwaySettings.ResidentialWidth; break; case PathType.Track: mat = _pathwaySettings.TrackMat; width = _pathwaySettings.TrackWidth; break; default: mat = _pathwaySettings.DefaultMat; width = _pathwaySettings.DefaultWidth; break; } // sharedMaterial avoids the LineRenderer cloning the project's // shared path Material on every BuildMap call. Queue overrides // dropped (P3 mobile-render regression cause). line.sharedMaterial = mat; line.widthMultiplier = width; // Nastavení bodů cesty - kPathY sits above all area polygons but // below building bases, so paths visibly run on top of areas. line.positionCount = w.Points.Count; for (int i = 0; i < w.Points.Count; i++) { Vector3 pos = w.Points[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center); pos.y = kPathY; line.SetPosition(i, pos); } return path; } GameObject BuildAreaMesh(MapArea a) { var area = new GameObject($"Area_{a.Name ?? "Unknown"}"); MeshFilter meshFilter = area.AddComponent(); MeshRenderer meshRenderer = area.AddComponent(); // Vytvoření plochého mesh Mesh mesh = CreateFlatPolygonMesh(a.Outline); meshFilter.mesh = mesh; Material mat; switch (a.AreaType) { case MapAreaType.Park: mat = _areaSettings.ParkMat; break; case MapAreaType.Garden: mat = _areaSettings.GardenMat; break; case MapAreaType.Playground: mat = _areaSettings.PlaygroundMat; break; case MapAreaType.Forest: mat = _areaSettings.ForestMat; break; case MapAreaType.Grass: mat = _areaSettings.GrassMat; break; case MapAreaType.Water: mat = _areaSettings.WaterMat; break; default: mat = _areaSettings.DefaultMat; break; } // sharedMaterial: no per-area material clone. Render-queue forcing // dropped in P8 (caused mobile-render regression). The Y-stagger // below alone now drives "smaller polygon on top of larger one" // depth ordering - which is what the depth buffer was always // designed to do, and works on mobile GPUs with weak precision // because the stagger spread (0.04 units) is well above any // reasonable depth-buffer epsilon. meshRenderer.sharedMaterial = mat; // Y stagger: smaller polygons sit a hair higher than larger ones, // so depth-test draws them on top of bigger area polygons they sit // inside (e.g. a playground inside a park). Total spread is 0.04 // units - visually invisible but plenty for the depth buffer. float yStagger = ComputeAreaYStagger(a.Outline); area.transform.position = new Vector3(0, kAreaBaseY + yStagger, 0); return area; } /// /// Returns a non-negative size proxy used to bucket areas by footprint. /// Larger polygons return higher numbers; used inversely for queue/Y. /// private float AreaSizeBucket(List outline) { if (outline == null || outline.Count < 3) return 1f; // Cheap bbox area in lat-lon space scaled by 1e6 - we only need a // monotonic ordering, not a real geographic area. double minLat = outline[0].Lat, maxLat = outline[0].Lat; double minLon = outline[0].Lon, maxLon = outline[0].Lon; for (int i = 1; i < outline.Count; i++) { if (outline[i].Lat < minLat) minLat = outline[i].Lat; if (outline[i].Lat > maxLat) maxLat = outline[i].Lat; if (outline[i].Lon < minLon) minLon = outline[i].Lon; if (outline[i].Lon > maxLon) maxLon = outline[i].Lon; } double bbox = (maxLat - minLat) * (maxLon - minLon) * 1e6; return (float)System.Math.Max(0.001, bbox); } /// /// Smaller areas get a higher Y so they render on top of any larger /// area they overlap. Returns a value in [0, 0.04] units. /// private float ComputeAreaYStagger(List outline) { float bucket = AreaSizeBucket(outline); // Inverse mapping: huge area -> 0, tiny area -> 0.04. float t = Mathf.Clamp01(1f - bucket / (bucket + 50f)); return t * 0.04f; } #endregion #region Polygon Utils private Vector3 CalculatePolygonCenter(List points) { Vector3 center = Vector3.zero; foreach (var point in points) { center += point.ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center); } return center / points.Count; } /// /// Signed XZ shoelace area for a polygon expressed in local Vector3. /// Positive = CCW (Unity left-handed Y-up: upward-facing normal), /// negative = CW (downward-facing normal -> top face invisible from /// above unless we reverse the winding before triangulating). /// private static float PolygonSignedAreaXZ(List verts) { float area = 0f; int n = verts.Count; for (int i = 0; i < n; i++) { var a = verts[i]; var b = verts[(i + 1) % n]; area += (b.x - a.x) * (a.z + b.z); } return area * 0.5f; } private Mesh CreateExtrudedPolygonMesh(List outline, float height) { Mesh mesh = new Mesh(); // Reject degenerates - Recast/Overpass can hand back 1-2 vertex // outlines on broken ways. Empty mesh -> renderer draws nothing, // safer than a malformed triangle list. if (outline == null || outline.Count < 3) return mesh; // Convert to local space first so we can run a winding check, then // reverse if needed. Without this, CW outlines from Overpass yield // downward-facing top normals and the building roof is invisible // from the top-down map camera. int vertexCount = outline.Count; var localVerts = new List(vertexCount); Vector3 center = CalculatePolygonCenter(outline); for (int i = 0; i < vertexCount; i++) localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center); if (PolygonSignedAreaXZ(localVerts) < 0f) localVerts.Reverse(); // Vertices - spodní a horní podstava Vector3[] vertices = new Vector3[vertexCount * 2]; for (int i = 0; i < vertexCount; i++) { Vector3 pos = localVerts[i]; vertices[i] = pos; // Spodní vertices[i + vertexCount] = pos + Vector3.up * height; // Horní } // Triangles - jen boční stěny pro jednoduchost List triangles = new List(); for (int i = 0; i < vertexCount; i++) { int next = (i + 1) % vertexCount; // Boční stěna - dva trojúhelníky triangles.Add(i); triangles.Add(i + vertexCount); triangles.Add(next); triangles.Add(next); triangles.Add(i + vertexCount); triangles.Add(next + vertexCount); } // Horní podstava - zjednodušená triangulace (fan) if (vertexCount >= 3) { for (int i = 1; i < vertexCount - 1; i++) { triangles.Add(vertexCount); // Střed (první bod horní) triangles.Add(vertexCount + i); triangles.Add(vertexCount + i + 1); } } mesh.vertices = vertices; mesh.triangles = triangles.ToArray(); mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; } private Mesh CreateFlatPolygonMesh(List outline) { Mesh mesh = new Mesh(); // Reject degenerates (matches CreateExtrudedPolygonMesh). if (outline == null || outline.Count < 3) return mesh; int vertexCount = outline.Count; var localVerts = new List(vertexCount); Vector3 center = CalculatePolygonCenter(outline); for (int i = 0; i < vertexCount; i++) localVerts.Add(outline[i].ToLocalVector3(_gameClient.CurrentLobbyState.MapData.Center) - center); // Force CCW so RecalculateNormals produces an upward-facing normal. // CW polygons from Overpass would otherwise render as black voids // when the top-down camera looks at their back face. if (PolygonSignedAreaXZ(localVerts) < 0f) localVerts.Reverse(); Vector3[] vertices = localVerts.ToArray(); // Triangulace - fan pattern List triangles = new List(); for (int i = 1; i < vertexCount - 1; i++) { triangles.Add(0); triangles.Add(i); triangles.Add(i + 1); } mesh.vertices = vertices; mesh.triangles = triangles.ToArray(); mesh.RecalculateNormals(); return mesh; } #endregion #region Markers public void CreateTaskMarkers(List tasks) { if (_mapCenterPoint == null) return; if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) { var md = _gameClient?.CurrentLobbyState?.MapData; if (md != null) _centerPosition = md.Center; } if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) return; var taskColor = new Color(0.20f, 0.95f, 0.55f); // bright green - "GO HERE" foreach (var task in tasks) { if (_taskMarkers.ContainsKey(task.TaskId)) continue; var pos = task.Location.ToLocalVector3(_centerPosition); var go = CreateMarkerWithLabel($"Task_{task.TaskId}", pos, taskColor, "TASK"); go.transform.parent = _mapCenterPoint.transform; // Pulsing point light so the task literally glows on the map. var light = go.AddComponent(); light.color = taskColor; light.intensity = 3f; light.range = 25f; _taskMarkers[task.TaskId] = go; } } public void RemoveTaskMarker(string taskId) { if (_taskMarkers.TryGetValue(taskId, out var go)) { UnityEngine.Object.Destroy(go); _taskMarkers.Remove(taskId); } } public void CreateBodyMarker(string bodyId, Position location) { if (_mapCenterPoint == null) return; if (_bodyMarkers.ContainsKey(bodyId)) return; var pos = location.ToLocalVector3(_centerPosition); // Bright red pillar with "BODY" label - players need to see this // from across the map to call it in. var go = CreateMarkerWithLabel($"Body_{bodyId}", pos, new Color(0.96f, 0.18f, 0.18f), "BODY"); go.transform.parent = _mapCenterPoint?.transform; _bodyMarkers[bodyId] = go; } public void ClearBodyMarkers() { foreach (var go in _bodyMarkers.Values) if (go) UnityEngine.Object.Destroy(go); _bodyMarkers.Clear(); } public void UpdatePlayerAvatars(Dictionary positions, string myUuid) { if (_mapCenterPoint == null) return; if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) { var md = _gameClient?.CurrentLobbyState?.MapData; if (md != null) _centerPosition = md.Center; } if (_centerPosition.Lat == 0 && _centerPosition.Lon == 0) return; foreach (var kvp in positions) { string uuid = kvp.Key; var info = kvp.Value; if (!_playerAvatars.TryGetValue(uuid, out var go) || go == null) { go = GameObject.CreatePrimitive(PrimitiveType.Capsule); go.name = $"Player_{uuid.Substring(0, Mathf.Min(8, uuid.Length))}"; go.transform.parent = _mapCenterPoint?.transform; go.transform.localScale = Vector3.one * 0.4f; _playerAvatars[uuid] = go; } go.transform.position = info.Position.ToLocalVector3(_centerPosition) + Vector3.up * 1f; var mr = go.GetComponent(); if (mr) { if (uuid == myUuid) mr.material.color = Color.green; else if (info.State == GeoSus.Client.PlayerState.Dead) mr.material.color = Color.grey; else mr.material.color = Color.white; } } } public void CreateSabotageMarkers(List stations) { var color = new Color(1.0f, 0.55f, 0.0f); // strong orange = repair urgency foreach (var station in stations) { var pos = station.Location.ToLocalVector3(_centerPosition); var go = CreateMarkerWithLabel($"Sabotage_{station.StationId}", pos, color, "REPAIR"); go.transform.parent = _mapCenterPoint?.transform; // Repair stations also pulse light so impostors and crew see // the urgency from across the map. var light = go.AddComponent(); light.color = color; light.intensity = 4f; light.range = 30f; _sabotageMarkers.Add(go); } } public void ClearSabotageMarkers() { foreach (var go in _sabotageMarkers) if (go) UnityEngine.Object.Destroy(go); _sabotageMarkers.Clear(); } #endregion } }