using System.Collections.Generic; using UnityEngine; // МОДУЛЬ ПОСЕЛЕНИЯ. Документ: Docs/Modules/Settlement.md // Сетка свободной стройки: игрок сам выбирает, где поставить здание. // // - Клетка = 1 единица мира. Сетка прямоугольная (арт города не изометрический). // - Где можно строить: прямоугольник вокруг старых участков (BuildSlot) и Ратуши с запасом padding. // Можно задать руками: autoArea = false и areaMin / areaMax. // - Занятые клетки: под каждым построенным зданием (его размер на сетке, BuildingData.footprint) и под Ратушей. // Старые сохранения: здание стоит, где стояло, занятость считается по тому, какие клетки оно накрывает. // - В режиме стройки сетка рисуется поверх земли: свободные клетки -- светлые линии, занятые -- красные. // - Старые участки и площадки «Застройка» прячутся при запуске (свободная стройка их заменила). // // Объект создаётся сам (BuildGrid.Instance), руками на сцену ставить не нужно. // Если поставить руками -- можно настроить поля в инспекторе. public class BuildGrid : MonoBehaviour { static BuildGrid instance; public static BuildGrid Instance { get { if (instance == null) { instance = FindFirstObjectByType(); if (instance == null) instance = new GameObject("BuildGrid").AddComponent(); } return instance; } } [Header("Где можно строить")] [Tooltip("Посчитать область самому: старые участки + Ратуша + запас")] public bool autoArea = true; [Tooltip("Запас вокруг старых участков, клеток")] public int padding = 3; public Vector2 areaMin = new Vector2(-34, -10); public Vector2 areaMax = new Vector2(4, 12); [Header("Вид сетки")] public int overlaySortingOrder = 1; public Color lineColor = new Color(1f, 1f, 1f, 0.28f); public Color freeColor = new Color(0.4f, 1f, 0.5f, 0.06f); public Color busyColor = new Color(1f, 0.15f, 0.15f, 0.38f); [Tooltip("Спрятать старые участки и площадки «Застройка» при запуске")] public bool hideLegacySlots = true; public Vector2Int Origin { get; private set; } // мировые координаты левого нижнего угла клетки (0, 0) public int Width { get; private set; } public int Height { get; private set; } bool[,] busy; bool initialized; float nextRefresh; SpriteRenderer overlay; Texture2D overlayTex; string lastOverlayKey; const int PixelsPerCell = 16; void Awake() { if (instance != null && instance != this) { Destroy(this); return; } instance = this; } void OnDestroy() { if (instance == this) instance = null; if (overlayTex != null) Destroy(overlayTex); } void Update() { if (overlay != null && overlay.enabled && Time.unscaledTime >= nextRefresh) Refresh(); } // ---------------- область ---------------- void EnsureInit() { if (initialized) return; initialized = true; Rect area = autoArea ? AutoArea() : Rect.MinMaxRect(areaMin.x, areaMin.y, areaMax.x, areaMax.y); Origin = new Vector2Int(Mathf.FloorToInt(area.xMin), Mathf.FloorToInt(area.yMin)); Width = Mathf.Max(1, Mathf.CeilToInt(area.xMax) - Origin.x); Height = Mathf.Max(1, Mathf.CeilToInt(area.yMax) - Origin.y); busy = new bool[Width, Height]; if (hideLegacySlots) HideLegacySlots(); Debug.Log($"[СТРОЙКА] Сетка {Width}×{Height} клеток, левый нижний угол {Origin}"); } Rect AutoArea() { bool any = false; Bounds b = new Bounds(); foreach (var slot in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { Bounds sb = new Bounds(slot.transform.position, Vector3.one); var col = slot.GetComponent(); if (col != null && col.enabled && col.gameObject.activeInHierarchy) sb.Encapsulate(col.bounds); if (!any) { b = sb; any = true; } else b.Encapsulate(sb); } if (TownHall.Instance != null) { Bounds tb = TownHallBounds(); if (!any) { b = tb; b.Expand(new Vector3(24, 16, 0)); any = true; } else b.Encapsulate(tb); } if (!any) b = new Bounds(Vector3.zero, new Vector3(30, 20, 0)); b.Expand(new Vector3(padding * 2, padding * 2, 0)); return Rect.MinMaxRect(b.min.x, b.min.y, b.max.x, b.max.y); } static Bounds TownHallBounds() { var th = TownHall.Instance; var col = th.GetComponent(); if (col != null && col.enabled) return col.bounds; var sr = th.GetComponentInChildren(); return sr != null ? sr.bounds : new Bounds(th.transform.position, new Vector3(3, 3, 0)); } static void HideLegacySlots() { foreach (var slot in FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { HideOn(slot.gameObject); var parent = slot.transform.parent; if (parent != null && parent.name.StartsWith("Застройка")) HideOn(parent.gameObject); } } // Прячем только сам объект (картинку и коллайдер), не детей -- и сцену не меняем static void HideOn(GameObject go) { foreach (var sr in go.GetComponents()) sr.enabled = false; foreach (var c in go.GetComponents()) c.enabled = false; } // ---------------- клетки ---------------- public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; public bool IsBusy(int x, int y) { EnsureInit(); return !InBounds(x, y) || busy[x, y]; } // Клетка мира -> индекс сетки public Vector2Int WorldToCell(Vector2 world) { EnsureInit(); return new Vector2Int(Mathf.FloorToInt(world.x) - Origin.x, Mathf.FloorToInt(world.y) - Origin.y); } // Центр прямоугольника клеток (x, y, w, h) в мире public Vector2 RectCenter(Vector2Int cell, Vector2Int size) { EnsureInit(); return new Vector2(Origin.x + cell.x + size.x * 0.5f, Origin.y + cell.y + size.y * 0.5f); } // Левая нижняя клетка для здания размера size, чей центр ближе всего к точке мира public Vector2Int SnapCell(Vector2 worldCenter, Vector2Int size) { EnsureInit(); return new Vector2Int(Mathf.RoundToInt(worldCenter.x - Origin.x - size.x * 0.5f), Mathf.RoundToInt(worldCenter.y - Origin.y - size.y * 0.5f)); } // Можно ли поставить: все клетки внутри области и свободны public bool CanPlace(Vector2Int cell, Vector2Int size) { EnsureInit(); for (int x = cell.x; x < cell.x + size.x; x++) for (int y = cell.y; y < cell.y + size.y; y++) if (!InBounds(x, y) || busy[x, y]) return false; return true; } // Ближайшее свободное место для здания размера size рядом с точкой (по спирали). false -- места нет public bool FindFreeNear(Vector2 worldCenter, Vector2Int size, out Vector2Int cell) { Refresh(); Vector2Int start = SnapCell(worldCenter, size); int maxR = Mathf.Max(Width, Height); for (int r = 0; r <= maxR; r++) { for (int dx = -r; dx <= r; dx++) for (int dy = -r; dy <= r; dy++) { if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; // только кольцо радиуса r var c = new Vector2Int(start.x + dx, start.y + dy); if (CanPlace(c, size)) { cell = c; return true; } } } cell = start; return false; } // Пересчитать занятые клетки по тому, что стоит в городе сейчас public void Refresh() { EnsureInit(); nextRefresh = Time.unscaledTime + 0.3f; System.Array.Clear(busy, 0, busy.Length); if (TownHall.Instance != null) { Bounds tb = TownHallBounds(); MarkRect(new Rect(tb.min.x, tb.min.y, tb.size.x, tb.size.y)); } var bm = BuildManager.Instance; foreach (var id in FindObjectsByType(FindObjectsSortMode.None)) { if (string.IsNullOrEmpty(id.buildingName)) continue; // здание сносится в этом кадре BuildingData data = bm != null ? bm.FindData(id.buildingName) : null; Rect r; if (data != null) { var fp = BuildFootprint.Of(data); Vector2 center = (Vector2)id.transform.position + fp.anchorOffset; r = new Rect(center.x - fp.size.x * 0.5f, center.y - fp.size.y * 0.5f, fp.size.x, fp.size.y); } else { var col = id.GetComponent(); if (col == null) continue; r = new Rect(col.bounds.min.x, col.bounds.min.y, col.bounds.size.x, col.bounds.size.y); } MarkRect(r); } if (overlay != null && overlay.enabled) RedrawOverlay(); } // Отметить все клетки, которые прямоугольник мира накрывает (касание краем не считается) void MarkRect(Rect r) { const float eps = 0.05f; int x0 = Mathf.FloorToInt(r.xMin + eps) - Origin.x, x1 = Mathf.CeilToInt(r.xMax - eps) - Origin.x; int y0 = Mathf.FloorToInt(r.yMin + eps) - Origin.y, y1 = Mathf.CeilToInt(r.yMax - eps) - Origin.y; for (int x = Mathf.Max(0, x0); x < Mathf.Min(Width, x1); x++) for (int y = Mathf.Max(0, y0); y < Mathf.Min(Height, y1); y++) busy[x, y] = true; } // ---------------- рисунок сетки ---------------- public void ShowOverlay(bool show) { EnsureInit(); if (show && overlay == null) CreateOverlay(); if (overlay == null) return; overlay.enabled = show; if (show) Refresh(); } void CreateOverlay() { overlayTex = new Texture2D(Width * PixelsPerCell, Height * PixelsPerCell, TextureFormat.RGBA32, false); overlayTex.filterMode = FilterMode.Point; overlayTex.wrapMode = TextureWrapMode.Clamp; var sprite = Sprite.Create(overlayTex, new Rect(0, 0, overlayTex.width, overlayTex.height), Vector2.zero, PixelsPerCell); var go = new GameObject("BuildGridOverlay"); go.transform.SetParent(transform, false); go.transform.position = new Vector3(Origin.x, Origin.y, 0f); overlay = go.AddComponent(); overlay.sprite = sprite; overlay.sortingOrder = overlaySortingOrder; lastOverlayKey = null; } void RedrawOverlay() { // перерисовываем только если занятость поменялась var key = new System.Text.StringBuilder(Width * Height); for (int y = 0; y < Height; y++) for (int x = 0; x < Width; x++) key.Append(busy[x, y] ? '1' : '0'); string k = key.ToString(); if (k == lastOverlayKey) return; lastOverlayKey = k; int w = overlayTex.width, h = overlayTex.height; var px = new Color32[w * h]; Color32 line = lineColor, free = freeColor, bad = busyColor; for (int y = 0; y < h; y++) { int cy = y / PixelsPerCell, iy = y % PixelsPerCell; for (int x = 0; x < w; x++) { int cx = x / PixelsPerCell, ix = x % PixelsPerCell; bool edge = ix == 0 || iy == 0 || x == w - 1 || y == h - 1; px[y * w + x] = busy[cx, cy] ? bad : (edge ? line : free); if (busy[cx, cy] && edge) px[y * w + x] = new Color32(bad.r, bad.g, bad.b, (byte)Mathf.Min(255, bad.a + 50)); } } overlayTex.SetPixels32(px); overlayTex.Apply(false); } } // Размер здания на сетке и где его "пятно" относительно точки, в которую ставится префаб. // size -- BuildingData.footprint, а если там 0 × 0 -- коллайдер префаба (иначе картинка), округлённый до клеток. // anchorOffset -- центр пятна минус позиция префаба (у многих картинок точка привязки не в центре). public struct BuildFootprint { public Vector2Int size; public Vector2 anchorOffset; static readonly Dictionary cache = new Dictionary(); public static BuildFootprint Of(BuildingData data) { if (data == null) return new BuildFootprint { size = Vector2Int.one }; if (cache.TryGetValue(data, out var fp)) return fp; Rect r = LocalRect(data.prefab); fp.anchorOffset = r.center; if (data.footprint.x > 0 && data.footprint.y > 0) fp.size = data.footprint; else fp.size = new Vector2Int(Mathf.Max(1, Mathf.RoundToInt(r.width)), Mathf.Max(1, Mathf.RoundToInt(r.height))); cache[data] = fp; return fp; } // Прямоугольник префаба относительно его позиции, в единицах мира. // Считаем по трансформам руками: у выключенного шаблона collider.bounds не работает. static Rect LocalRect(GameObject prefab) { if (prefab == null) return new Rect(-0.5f, -0.5f, 1, 1); Vector3 root = prefab.transform.position; var box = prefab.GetComponent(); if (box == null) box = prefab.GetComponentInChildren(true); if (box != null) { Vector2 half = box.size * 0.5f; return Corners(box.transform, box.offset - half, box.offset + half, root); } bool any = false; Rect result = new Rect(); foreach (var sr in prefab.GetComponentsInChildren(true)) { if (sr.sprite == null) continue; var b = sr.sprite.bounds; Rect one = Corners(sr.transform, b.min, b.max, root); if (!any) { result = one; any = true; } else result = Rect.MinMaxRect(Mathf.Min(result.xMin, one.xMin), Mathf.Min(result.yMin, one.yMin), Mathf.Max(result.xMax, one.xMax), Mathf.Max(result.yMax, one.yMax)); } return any ? result : new Rect(-0.5f, -0.5f, 1, 1); } static Rect Corners(Transform t, Vector2 min, Vector2 max, Vector3 root) { Vector3 a = t.TransformPoint(new Vector3(min.x, min.y)) - root; Vector3 b = t.TransformPoint(new Vector3(max.x, max.y)) - root; Vector3 c = t.TransformPoint(new Vector3(min.x, max.y)) - root; Vector3 d = t.TransformPoint(new Vector3(max.x, min.y)) - root; return Rect.MinMaxRect(Mathf.Min(a.x, b.x, c.x, d.x), Mathf.Min(a.y, b.y, c.y, d.y), Mathf.Max(a.x, b.x, c.x, d.x), Mathf.Max(a.y, b.y, c.y, d.y)); } }