Bogatyri.Settlement.Placement (без Unity): сетка, зоны по Ратуше, река/скалы/деревья/валуны, отступ, уклон и фундамент, поворот 0/90/180/270, атомарный перенос с тем же id, расчистка один раз; 7 тестов. Сцена City3D (Tools -> Bogatyri -> 3D-город): местность, призрак, перенос, расчистка, камера 2.5D, второй рендерер URP (3D) только для её камеры -- 2D-сцены не тронуты. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
14 KiB
C#
250 lines
14 KiB
C#
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<int, string> obstacleAt = new Dictionary<int, string>(); // клетка -> id дерева/валуна
|
||
private readonly Dictionary<string, List<int>> obstacleCells = new Dictionary<string, List<int>>();
|
||
private readonly HashSet<string> cleared = new HashSet<string>();
|
||
private readonly Dictionary<string, PlacedRect> buildings = new Dictionary<string, PlacedRect>();
|
||
|
||
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<int>();
|
||
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<string> 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<string> 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<PlacedRect> 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)
|
||
{
|
||
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 (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();
|
||
}
|
||
}
|
||
}
|
||
}
|