using System; using System.Collections.Generic; // РАЗМЕЩЕНИЕ ЗДАНИЙ В 3D-ПОСЕЛЕНИИ (ТЗ «Система строительства поселения» от 04.11.2026, этап I). // Документ: Docs/Modules/Settlement.md, раздел «3D-поселение». // // Без Unity: только клетки, зоны, препятствия и занятость -- те же правила будут на сервере и в тестах. // Отображение (местность, кубы-здания, призрак) -- в слое приложения (Assets/Scripts/City3D). // // Невидимая логическая сетка: клетка CellSize единиц мира. Здание занимает прямоугольник клеток // (его максимальный габарит -- с первого уровня, апгрейд клетки не расширяет). Поворот 0/90/180/270. // Числа -- ТЕСТ, предложение Саши на согласование (ТЗ, раздел 12). namespace Bogatyri.Settlement.Placement { public enum CellKind { Ground, // обычная земля Water, // река -- постоянное препятствие Mountain // гора, скала -- постоянное препятствие } // Почему здание нельзя поставить public enum PlaceError { None, OutOfMap, // за краем карты ZoneLocked, // зона ещё не открыта уровнем Ратуши Water, // река Mountain, // гора Obstacle, // дерево или валун -- можно расчистить Occupied, // другое здание TooClose, // нарушен технический отступ до другого здания TooSteep // слишком большой перепад высоты под зданием } public sealed class PlacementRules { public float CellSize = 1f; // размер клетки в единицах мира public int GapCells = 1; // технический отступ между основаниями зданий, клеток public int MaxStepMm = 700; // наибольший перепад высоты под зданием (мм); меньше -- фундамент, больше -- нельзя public static PlacementRules Default => new PlacementRules(); } // Здание на карте: прямоугольник клеток. X, Y -- левый нижний угол уже с учётом поворота public struct PlacedRect { public string Id; public string Type; public int X, Y; public int SizeW, SizeH; // габарит без поворота public int Rotation; // 0, 90, 180, 270 public int W => Rotation == 90 || Rotation == 270 ? SizeH : SizeW; public int H => Rotation == 90 || Rotation == 270 ? SizeW : SizeH; } public sealed class SettlementMap { public readonly int Width, Height; public readonly PlacementRules Rules; private readonly CellKind[] kind; private readonly int[] heightMm; // высота земли в центре клетки, мм private readonly int[] zoneLevel; // с какого уровня Ратуши клетка доступна для стройки (0 -- никогда) private readonly Dictionary obstacleAt = new Dictionary(); // клетка -> id дерева/валуна private readonly Dictionary> obstacleCells = new Dictionary>(); private readonly HashSet cleared = new HashSet(); private readonly Dictionary buildings = new Dictionary(); public SettlementMap(int width, int height, PlacementRules rules = null) { Width = Math.Max(1, width); Height = Math.Max(1, height); Rules = rules ?? PlacementRules.Default; kind = new CellKind[Width * Height]; heightMm = new int[Width * Height]; zoneLevel = new int[Width * Height]; } // ---------------- Описание карты (заполняет генератор местности) ---------------- public bool InMap(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; int Index(int x, int y) => y * Width + x; public void SetCell(int x, int y, CellKind k, int heightMillimeters, int openAtTownHall) { if (!InMap(x, y)) return; int i = Index(x, y); kind[i] = k; heightMm[i] = heightMillimeters; zoneLevel[i] = Math.Max(0, openAtTownHall); } public CellKind KindAt(int x, int y) => InMap(x, y) ? kind[Index(x, y)] : CellKind.Mountain; public int HeightAt(int x, int y) => InMap(x, y) ? heightMm[Index(x, y)] : 0; public int ZoneAt(int x, int y) => InMap(x, y) ? zoneLevel[Index(x, y)] : 0; public bool IsOpen(int x, int y, int townHallLevel) { int z = ZoneAt(x, y); return z > 0 && z <= townHallLevel; } // Дерево или валун: занимает клетки, пока его не расчистили. id -- постоянный в пределах поселения public void AddObstacle(string id, IEnumerable<(int x, int y)> cells) { if (string.IsNullOrEmpty(id) || obstacleCells.ContainsKey(id)) return; var list = new List(); foreach (var c in cells) { if (!InMap(c.x, c.y)) continue; int i = Index(c.x, c.y); if (obstacleAt.ContainsKey(i)) continue; obstacleAt[i] = id; list.Add(i); } obstacleCells[id] = list; if (cleared.Contains(id)) foreach (int i in list) obstacleAt.Remove(i); // уже расчищен (из сохранения) } public string ObstacleAt(int x, int y) => InMap(x, y) && obstacleAt.TryGetValue(Index(x, y), out string id) ? id : null; public bool IsCleared(string obstacleId) => cleared.Contains(obstacleId); public IReadOnlyCollection Cleared => cleared; // Расчистка: один раз на объект. true -- только что расчищен (за это одна награда); false -- нет такого или уже public bool ClearObstacle(string obstacleId) { if (string.IsNullOrEmpty(obstacleId) || !obstacleCells.TryGetValue(obstacleId, out var list) || cleared.Contains(obstacleId)) return false; cleared.Add(obstacleId); foreach (int i in list) obstacleAt.Remove(i); return true; } // Расчищенные из сохранения -- до или после AddObstacle, порядок не важен public void RestoreCleared(IEnumerable ids) { if (ids == null) return; foreach (var id in ids) { if (string.IsNullOrEmpty(id) || !cleared.Add(id)) continue; if (obstacleCells.TryGetValue(id, out var list)) foreach (int i in list) obstacleAt.Remove(i); } } // ---------------- Здания ---------------- public IReadOnlyCollection Buildings => buildings.Values; public bool TryGetBuilding(string id, out PlacedRect rect) => buildings.TryGetValue(id ?? "", out rect); public string BuildingAt(int x, int y) { foreach (var b in buildings.Values) if (x >= b.X && y >= b.Y && x < b.X + b.W && y < b.Y + b.H) return b.Id; return null; } // Проверить место: прямоугольник sizeW x sizeH с поворотом rotation, левый нижний угол (x, y). // ignoreId -- здание, которое переносят: его прежний след не мешает. foundationMm -- высота фундамента public PlaceError Validate(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm) => ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, false, out foundationMm); // То же, но деревья и валуны не мешают: можно ли поставить здесь, если расчистить (перенос 2D-города, 05.11) public PlaceError ValidateIfCleared(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm) => ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, true, out foundationMm); // Деревья и валуны (id) под прямоугольником -- что придётся расчистить. Каждый -- один раз public List ObstaclesUnder(int x, int y, int sizeW, int sizeH, int rotation) { rotation = NormalizeRotation(rotation); int w = rotation == 90 || rotation == 270 ? sizeH : sizeW; int h = rotation == 90 || rotation == 270 ? sizeW : sizeH; var ids = new List(); for (int cy = y; cy < y + h; cy++) for (int cx = x; cx < x + w; cx++) { string id = ObstacleAt(cx, cy); if (id != null && !ids.Contains(id)) ids.Add(id); } return ids; } PlaceError ValidateCore(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, bool ignoreObstacles, out int foundationMm) { foundationMm = 0; rotation = NormalizeRotation(rotation); int w = rotation == 90 || rotation == 270 ? sizeH : sizeW; int h = rotation == 90 || rotation == 270 ? sizeW : sizeH; if (w <= 0 || h <= 0 || x < 0 || y < 0 || x + w > Width || y + h > Height) return PlaceError.OutOfMap; int min = int.MaxValue, max = int.MinValue; for (int cy = y; cy < y + h; cy++) for (int cx = x; cx < x + w; cx++) { int i = Index(cx, cy); if (kind[i] == CellKind.Water) return PlaceError.Water; if (kind[i] == CellKind.Mountain) return PlaceError.Mountain; if (!IsOpen(cx, cy, townHallLevel)) return PlaceError.ZoneLocked; if (!ignoreObstacles && obstacleAt.ContainsKey(i)) return PlaceError.Obstacle; min = Math.Min(min, heightMm[i]); max = Math.Max(max, heightMm[i]); } int gap = Math.Max(0, Rules.GapCells); foreach (var b in buildings.Values) { if (b.Id == ignoreId) continue; if (Overlap(x, y, w, h, b.X, b.Y, b.W, b.H)) return PlaceError.Occupied; if (Overlap(x - gap, y - gap, w + 2 * gap, h + 2 * gap, b.X, b.Y, b.W, b.H)) return PlaceError.TooClose; } if (max - min > Rules.MaxStepMm) return PlaceError.TooSteep; foundationMm = max - min; // небольшой перепад -- площадка под зданием (верх -- по самой высокой клетке) return PlaceError.None; } // Поставить новое здание. Место проверяется теми же правилами; false -- ничего не изменилось public PlaceError Place(string id, string type, int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel) { if (string.IsNullOrEmpty(id) || buildings.ContainsKey(id)) return PlaceError.Occupied; PlaceError e = Validate(x, y, sizeW, sizeH, rotation, townHallLevel, null, out _); if (e != PlaceError.None) return e; buildings[id] = new PlacedRect { Id = id, Type = type, X = x, Y = y, SizeW = sizeW, SizeH = sizeH, Rotation = NormalizeRotation(rotation) }; return PlaceError.None; } // Перенос (ТЗ, раздел 5): то же здание с тем же id -- меняются только позиция и поворот, целиком или никак. // Свой прежний след не мешает. Ошибка -- здание остаётся, где было public PlaceError Move(string id, int x, int y, int rotation, int townHallLevel) { if (!buildings.TryGetValue(id ?? "", out PlacedRect b)) return PlaceError.OutOfMap; PlaceError e = Validate(x, y, b.SizeW, b.SizeH, rotation, townHallLevel, id, out _); if (e != PlaceError.None) return e; b.X = x; b.Y = y; b.Rotation = NormalizeRotation(rotation); buildings[id] = b; return PlaceError.None; } // Восстановление из сохранения: без проверки зон (зона могла быть открыта раньше), но без наложений public bool Restore(PlacedRect rect) { if (string.IsNullOrEmpty(rect.Id) || buildings.ContainsKey(rect.Id)) return false; rect.Rotation = NormalizeRotation(rect.Rotation); foreach (var b in buildings.Values) if (Overlap(rect.X, rect.Y, rect.W, rect.H, b.X, b.Y, b.W, b.H)) return false; buildings[rect.Id] = rect; return true; } public static int NormalizeRotation(int rotation) { int r = ((rotation % 360) + 360) % 360; return r / 90 * 90; } static bool Overlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah; } public static string ErrorText(PlaceError e) { switch (e) { case PlaceError.None: return "Можно ставить"; case PlaceError.OutOfMap: return "За краем карты"; case PlaceError.ZoneLocked: return "Зона не открыта: нужен уровень Ратуши выше"; case PlaceError.Water: return "Здесь река"; case PlaceError.Mountain: return "Здесь скалы"; case PlaceError.Obstacle: return "Мешает дерево или валун -- расчистите"; case PlaceError.Occupied: return "Место занято другим зданием"; case PlaceError.TooClose: return "Слишком близко к другому зданию"; case PlaceError.TooSteep: return "Слишком крутой склон"; default: return e.ToString(); } } } }