From a3c4a99a7989129bc21cf938743dddb069a49baf Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 14:50:22 +0100 Subject: [PATCH] =?UTF-8?q?3D-=D0=BF=D0=BE=D1=81=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D1=8D=D1=82=D0=B0=D0=BF=20I:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B8=D1=82=D0=B8=D0=B2=D0=B0=D1=85=20(?= =?UTF-8?q?=D0=A2=D0=97=20=D0=BE=D1=82=2004.11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Assets/Editor/City3DSetup.cs | 98 +++ Assets/Editor/City3DSetup.cs.meta | 11 + Assets/Scripts/City3D.meta | 8 + Assets/Scripts/City3D/City3DCamera.cs | 106 +++ Assets/Scripts/City3D/City3DCamera.cs.meta | 11 + Assets/Scripts/City3D/City3DController.cs | 701 ++++++++++++++++++ .../Scripts/City3D/City3DController.cs.meta | 11 + Assets/Scripts/City3D/City3DLayout.cs | 97 +++ Assets/Scripts/City3D/City3DLayout.cs.meta | 11 + .../Scripts/Modules/Settlement/Placement.meta | 8 + .../Bogatyri.Settlement.Placement.asmdef | 14 + .../Bogatyri.Settlement.Placement.asmdef.meta | 7 + .../Settlement/Placement/SettlementMap.cs | 249 +++++++ .../Placement/SettlementMap.cs.meta | 11 + .../Modules/Tests/Bogatyri.Tests.asmdef | 3 +- .../Scripts/Modules/Tests/PlacementTests.cs | 121 +++ .../Modules/Tests/PlacementTests.cs.meta | 11 + Docs/Modules/README.md | 1 + Docs/Modules/Settlement.md | 25 + 19 files changed, 1503 insertions(+), 1 deletion(-) create mode 100644 Assets/Editor/City3DSetup.cs create mode 100644 Assets/Editor/City3DSetup.cs.meta create mode 100644 Assets/Scripts/City3D.meta create mode 100644 Assets/Scripts/City3D/City3DCamera.cs create mode 100644 Assets/Scripts/City3D/City3DCamera.cs.meta create mode 100644 Assets/Scripts/City3D/City3DController.cs create mode 100644 Assets/Scripts/City3D/City3DController.cs.meta create mode 100644 Assets/Scripts/City3D/City3DLayout.cs create mode 100644 Assets/Scripts/City3D/City3DLayout.cs.meta create mode 100644 Assets/Scripts/Modules/Settlement/Placement.meta create mode 100644 Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef create mode 100644 Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef.meta create mode 100644 Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs create mode 100644 Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs.meta create mode 100644 Assets/Scripts/Modules/Tests/PlacementTests.cs create mode 100644 Assets/Scripts/Modules/Tests/PlacementTests.cs.meta diff --git a/Assets/Editor/City3DSetup.cs b/Assets/Editor/City3DSetup.cs new file mode 100644 index 0000000..a70690e --- /dev/null +++ b/Assets/Editor/City3DSetup.cs @@ -0,0 +1,98 @@ +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; +using UnityEngine.SceneManagement; + +// Создаёт сцену 3D-поселения (прототип, этап I ТЗ «Система строительства поселения» от 04.11.2026): +// Tools -> Bogatyri -> 3D-город: создать сцену City3D. +// +// Что делает: +// 1. Добавляет в настройки URP второй рендерер -- обычный 3D (Assets/Settings/Renderer3D.asset). +// Старые 2D-сцены продолжают рисоваться 2D-рендерером: он остаётся рендерером по умолчанию. +// 2. Создаёт Assets/Scenes/City3D.unity: камера (3D-рендерер, перспектива), солнце с тенями, +// объект City3D с City3DController -- всё остальное (земля, деревья, здания) он строит сам при запуске. +// 3. Добавляет сцену в Build Settings и открывает её. Дальше -- просто Play. +public static class City3DSetup +{ + const string ScenePath = "Assets/Scenes/City3D.unity"; + const string RendererPath = "Assets/Settings/Renderer3D.asset"; + + [MenuItem("Tools/Bogatyri/3D-город: создать сцену City3D")] + static void Create() + { + if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return; + + int rendererIndex = EnsureRenderer3D(); + + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + var camGo = new GameObject("Main Camera") { tag = "MainCamera" }; + var cam = camGo.AddComponent(); + cam.orthographic = false; + cam.fieldOfView = 40f; + cam.nearClipPlane = 0.3f; + cam.farClipPlane = 300f; + cam.clearFlags = CameraClearFlags.SolidColor; + cam.backgroundColor = new Color(0.55f, 0.72f, 0.9f); + camGo.AddComponent(); + var data = camGo.AddComponent(); + if (rendererIndex >= 0) data.SetRenderer(rendererIndex); + camGo.AddComponent(); + + var sunGo = new GameObject("Sun"); + var sun = sunGo.AddComponent(); + sun.type = LightType.Directional; + sun.intensity = 1.2f; + sun.shadows = LightShadows.Soft; + sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f); + RenderSettings.ambientMode = AmbientMode.Flat; + RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f); + + new GameObject("City3D").AddComponent(); + + EditorSceneManager.SaveScene(scene, ScenePath); + AddToBuildSettings(); + Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play."); + } + + // Второй рендерер URP -- обычный (3D). Возвращает его индекс в списке рендереров (или -1) + static int EnsureRenderer3D() + { + var pipeline = GraphicsSettings.defaultRenderPipeline as UniversalRenderPipelineAsset; + if (pipeline == null) + { + Debug.LogWarning("[3D-ГОРОД] В Graphics Settings нет URP Asset -- камера будет рисовать рендерером по умолчанию"); + return -1; + } + + var renderer = AssetDatabase.LoadAssetAtPath(RendererPath); + if (renderer == null) + { + renderer = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(renderer, RendererPath); + AssetDatabase.SaveAssets(); + } + + var so = new SerializedObject(pipeline); + var list = so.FindProperty("m_RendererDataList"); + for (int i = 0; i < list.arraySize; i++) + if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) return i; + + list.arraySize++; + list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer; + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(pipeline); + AssetDatabase.SaveAssets(); + return list.arraySize - 1; + } + + static void AddToBuildSettings() + { + var scenes = new System.Collections.Generic.List(EditorBuildSettings.scenes); + foreach (var s in scenes) if (s.path == ScenePath) return; + scenes.Add(new EditorBuildSettingsScene(ScenePath, true)); + EditorBuildSettings.scenes = scenes.ToArray(); + } +} diff --git a/Assets/Editor/City3DSetup.cs.meta b/Assets/Editor/City3DSetup.cs.meta new file mode 100644 index 0000000..a868980 --- /dev/null +++ b/Assets/Editor/City3DSetup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 37d458150cc84d538a23b4c6612995c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/City3D.meta b/Assets/Scripts/City3D.meta new file mode 100644 index 0000000..1cceba9 --- /dev/null +++ b/Assets/Scripts/City3D.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: efa023adb1ce408c971a4f76ced19585 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/City3D/City3DCamera.cs b/Assets/Scripts/City3D/City3DCamera.cs new file mode 100644 index 0000000..fb420e7 --- /dev/null +++ b/Assets/Scripts/City3D/City3DCamera.cs @@ -0,0 +1,106 @@ +using UnityEngine; + +// КАМЕРА 3D-ПОСЕЛЕНИЯ (2.5D): наклонный вид под фиксированным углом, без вращения. +// Перемещение -- перетаскиванием (палец / мышь), приближение -- двумя пальцами или колесом. +// Не уходит за пределы полезной карты. FocusOn -- плавно показать область (открытие новой зоны). +public class City3DCamera : MonoBehaviour +{ + public float pitch = 52f; // наклон, градусы от горизонта + public float yaw = 45f; // поворот вида вокруг вертикали + public float distance = 26f; + public float minDistance = 8f; + public float maxDistance = 55f; + + public Vector3 target; // точка на земле, на которую смотрит камера + public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z) + + Camera cam; + Vector3 focusTarget; + float focusDistance; + bool focusing; + Vector3 lastPointer; + bool panning; + float lastPinch; + + // Перемещение камеры пальцем можно выключить на время перетаскивания здания + public bool AllowPan { get; set; } = true; + + void Awake() + { + cam = GetComponent(); + } + + public void FocusOn(Rect area) + { + focusTarget = new Vector3(area.center.x, 0, area.center.y); + // отойти так, чтобы область поместилась по высоте экрана (с запасом) + float size = Mathf.Max(area.width, area.height); + focusDistance = Mathf.Clamp(size * 1.15f, minDistance, maxDistance); + focusing = true; + } + + // Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем) + public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; } + public void EndPan() { panning = false; } + + public void Pan(Vector3 screen) + { + if (!panning || cam == null) return; + Vector3 a = GroundPoint(lastPointer), b = GroundPoint(screen); + target += a - b; + lastPointer = screen; + Clamp(); + } + + public void Zoom(float delta) + { + focusing = false; + distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance); + } + + void Update() + { + // Колесо мыши (редактор, ПК) + float wheel = Input.mouseScrollDelta.y; + if (Mathf.Abs(wheel) > 0.01f) Zoom(wheel * 0.1f); + + // Два пальца -- приближение + if (Input.touchCount == 2) + { + float d = Vector2.Distance(Input.GetTouch(0).position, Input.GetTouch(1).position); + if (Input.GetTouch(1).phase == TouchPhase.Began) lastPinch = d; + else if (lastPinch > 0) { Zoom((d - lastPinch) / Mathf.Max(200f, Screen.height)); lastPinch = d; } + } + else lastPinch = 0; + + if (focusing) + { + target = Vector3.Lerp(target, focusTarget, Time.unscaledDeltaTime * 2.5f); + distance = Mathf.Lerp(distance, focusDistance, Time.unscaledDeltaTime * 2.5f); + if (Vector3.Distance(target, focusTarget) < 0.05f && Mathf.Abs(distance - focusDistance) < 0.05f) focusing = false; + } + Apply(); + } + + void Clamp() + { + target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax); + target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax); + target.y = 0; + } + + public void Apply() + { + Quaternion rot = Quaternion.Euler(pitch, yaw, 0); + transform.rotation = rot; + transform.position = target - rot * Vector3.forward * distance; + } + + // Точка на плоскости земли (y = 0) под экранной точкой + Vector3 GroundPoint(Vector3 screen) + { + Ray ray = cam.ScreenPointToRay(screen); + var plane = new Plane(Vector3.up, Vector3.zero); + return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target; + } +} diff --git a/Assets/Scripts/City3D/City3DCamera.cs.meta b/Assets/Scripts/City3D/City3DCamera.cs.meta new file mode 100644 index 0000000..4d96a28 --- /dev/null +++ b/Assets/Scripts/City3D/City3DCamera.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8645f0028574904969b5802443f100f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs new file mode 100644 index 0000000..c850503 --- /dev/null +++ b/Assets/Scripts/City3D/City3DController.cs @@ -0,0 +1,701 @@ +using System.Collections.Generic; +using Bogatyri.Settlement.Placement; +using TMPro; +using UnityEngine; + +// 3D-ПОСЕЛЕНИЕ, ЭТАП I -- ПРОТОТИП НА ПРИМИТИВАХ (ТЗ «Система строительства поселения» от 04.11.2026). +// Сцена Assets/Scenes/City3D.unity (создаётся: Tools -> Bogatyri -> 3D-город: создать сцену City3D). +// +// Что умеет: +// - местность с холмами, рекой, скалами, деревьями, валунами и травой (City3DLayout); +// - невидимая сетка: видна только в режиме стройки / редактирования вместе с границей открытой зоны; +// - новое здание: выбрать тип -> призрак-куб тянется пальцем, привязан к клеткам, зелёный / красный с причиной, +// поворот 0/90/180/270, «Поставить» / «Отмена»; +// - перенос: кнопка в меню здания или общий режим редактирования; то же здание (тот же id), подтверждение / отмена; +// - расчистка дерева / валуна (без ожидания и цены); трава прячется под зданием и возвращается; +// - зоны по уровню Ратуши (кнопки «Ратуша -/+» -- ТОЛЬКО ДЛЯ ТЕСТА), камера плавно показывает новую зону. +// Правила места -- в модуле Поселения (Bogatyri.Settlement.Placement, тесты PlacementTests), здесь только показ и ввод. +// +// Пока НЕ подключено (этапы II-III): цена и таймер стройки, Экономика и награда за расчистку, сохранение, +// дороги, настоящие модели. Кнопки -- временные (IMGUI); окна согласуются с Володей. +public class City3DController : MonoBehaviour +{ + // Тип здания для прототипа: максимальный габарит (клеток) с первого уровня, высота куба, цвет + struct BuildingType + { + public string Name; + public int W, H; + public float Height; + public Color Color; + } + + static readonly BuildingType[] Types = + { + new BuildingType { Name = "Ратуша", W = 4, H = 4, Height = 3.0f, Color = new Color(0.85f, 0.75f, 0.35f) }, + new BuildingType { Name = "Дом", W = 2, H = 2, Height = 1.4f, Color = new Color(0.85f, 0.55f, 0.35f) }, + new BuildingType { Name = "Ферма", W = 3, H = 3, Height = 1.0f, Color = new Color(0.9f, 0.85f, 0.4f) }, + new BuildingType { Name = "Лесопилка", W = 3, H = 2, Height = 1.5f, Color = new Color(0.55f, 0.4f, 0.25f) }, + new BuildingType { Name = "Каменоломня", W = 3, H = 2, Height = 1.3f, Color = new Color(0.6f, 0.6f, 0.62f) }, + new BuildingType { Name = "Шахта", W = 2, H = 2, Height = 1.5f, Color = new Color(0.4f, 0.45f, 0.55f) }, + new BuildingType { Name = "Склад", W = 2, H = 3, Height = 1.8f, Color = new Color(0.65f, 0.5f, 0.3f) }, + new BuildingType { Name = "Амбар", W = 2, H = 3, Height = 1.8f, Color = new Color(0.8f, 0.65f, 0.3f) }, + new BuildingType { Name = "Казармы", W = 3, H = 3, Height = 2.0f, Color = new Color(0.75f, 0.3f, 0.3f) }, + new BuildingType { Name = "Госпиталь", W = 3, H = 3, Height = 1.8f, Color = new Color(0.85f, 0.95f, 1f) }, + }; + + enum Mode { View, Edit, Place, Move } + + City3DLayout layout; + SettlementMap map; + City3DCamera cameraRig; + Camera cam; + Material baseMaterial; + Texture2D groundTexture; + const int PixelsPerCell = 8; + + readonly Dictionary buildingViews = new Dictionary(); + readonly Dictionary buildingType = new Dictionary(); + readonly Dictionary obstacleViews = new Dictionary(); + readonly List grassViews = new List(); + int nextBuildingId = 1; + int townHallLevel = 1; + + Mode mode = Mode.View; + int placingType = -1; // Place: какой тип ставим + string movingId; // Move: какое здание переносим + int ghostX, ghostY, ghostRot; + PlacedRect moveOriginal; // Move: последнее подтверждённое положение + GameObject ghost, footprint; + Material okMaterial, badMaterial; + PlaceError ghostError; + int ghostFoundation; + bool draggingGhost; + Vector3 pointerDown; + bool pointerMoved, pointerOverGui; + + string selectedBuilding, selectedObstacle; // меню здания / препятствия (тап в обычном режиме) + string message = ""; + float messageUntil; + + // ---------------- Построение сцены ---------------- + + void Start() + { + cam = Camera.main; + cameraRig = cam.GetComponent() ?? cam.gameObject.AddComponent(); + cameraRig.bounds = new Rect(2, 2, City3DLayout.Size - 4, City3DLayout.Size - 4); + cameraRig.target = new Vector3(City3DLayout.Size / 2f, 0, City3DLayout.Size / 2f); + cameraRig.Apply(); + + // Материал URP по умолчанию берём у примитива: шейдер точно есть в сборке + var probe = GameObject.CreatePrimitive(PrimitiveType.Cube); + baseMaterial = new Material(probe.GetComponent().sharedMaterial); + Destroy(probe); + + layout = City3DLayout.Create(); + map = layout.Map; + BuildGround(); + BuildObstacles(); + BuildGrass(); + + // Ратуша стоит в центре с самого начала + int c = City3DLayout.Size / 2; + PlaceNew(0, c - 2, c - 2, 0); + RefreshGround(); + } + + Material Mat(Color color) + { + var m = new Material(baseMaterial) { color = color }; + return m; + } + + void BuildGround() + { + int n = City3DLayout.Size; + var verts = new Vector3[(n + 1) * (n + 1)]; + var uvs = new Vector2[verts.Length]; + for (int z = 0; z <= n; z++) + for (int x = 0; x <= n; x++) + { + int i = z * (n + 1) + x; + verts[i] = new Vector3(x, City3DLayout.HeightAt(x, z), z); + uvs[i] = new Vector2(x / (float)n, z / (float)n); + } + var tris = new int[n * n * 6]; + int t = 0; + for (int z = 0; z < n; z++) + for (int x = 0; x < n; x++) + { + int i = z * (n + 1) + x; + tris[t++] = i; tris[t++] = i + n + 1; tris[t++] = i + 1; + tris[t++] = i + 1; tris[t++] = i + n + 1; tris[t++] = i + n + 2; + } + var mesh = new Mesh { name = "City3D_Ground", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + mesh.vertices = verts; + mesh.uv = uvs; + mesh.triangles = tris; + mesh.RecalculateNormals(); + + var go = new GameObject("Ground"); + go.transform.SetParent(transform, false); + go.AddComponent().sharedMesh = mesh; + groundTexture = new Texture2D(n * PixelsPerCell, n * PixelsPerCell, TextureFormat.RGBA32, false) + { + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp + }; + var mat = Mat(Color.white); + mat.mainTexture = groundTexture; + go.AddComponent().sharedMaterial = mat; + go.AddComponent().sharedMesh = mesh; + + // Вода -- плоскость над руслом + var water = GameObject.CreatePrimitive(PrimitiveType.Cube); + water.name = "River"; + Destroy(water.GetComponent()); + water.transform.SetParent(transform, false); + water.transform.position = new Vector3(37f, -0.25f, n / 2f); + water.transform.localScale = new Vector3(9f, 0.05f, n); + water.GetComponent().sharedMaterial = Mat(new Color(0.25f, 0.5f, 0.85f)); + } + + void BuildObstacles() + { + var trunk = Mat(new Color(0.45f, 0.3f, 0.18f)); + var leaves = Mat(new Color(0.18f, 0.48f, 0.2f)); + var stone = Mat(new Color(0.55f, 0.55f, 0.58f)); + foreach (var o in layout.Obstacles) + { + var root = new GameObject(o.Id); + root.transform.SetParent(transform, false); + Vector2 center = Vector2.zero; + foreach (var c in o.Cells) center += new Vector2(c.x + 0.5f, c.y + 0.5f); + center /= o.Cells.Count; + float h = City3DLayout.HeightAt(center.x, center.y); + root.transform.position = new Vector3(center.x, h, center.y); + if (o.IsTree) + { + Part(root, PrimitiveType.Cylinder, new Vector3(0, 0.5f, 0), new Vector3(0.25f, 0.5f, 0.25f), trunk); + Part(root, PrimitiveType.Sphere, new Vector3(0, 1.4f, 0), new Vector3(1.1f, 1.3f, 1.1f), leaves); + } + else + { + float s = o.Cells.Count > 1 ? 1.8f : 0.9f; + Part(root, PrimitiveType.Sphere, new Vector3(0, s * 0.3f, 0), new Vector3(s, s * 0.7f, s * 0.9f), stone); + } + obstacleViews[o.Id] = root; + } + } + + void BuildGrass() + { + var grass = Mat(new Color(0.35f, 0.65f, 0.3f)); + foreach (var p in layout.Grass) + { + var g = GameObject.CreatePrimitive(PrimitiveType.Cube); + Destroy(g.GetComponent()); + g.name = "Grass"; + g.transform.SetParent(transform, false); + g.transform.position = new Vector3(p.x, City3DLayout.HeightAt(p.x, p.y) + 0.08f, p.y); + g.transform.localScale = new Vector3(0.25f, 0.16f, 0.25f); + g.transform.rotation = Quaternion.Euler(0, p.x * 37f, 0); + g.GetComponent().sharedMaterial = grass; + grassViews.Add(g); + } + } + + static GameObject Part(GameObject parent, PrimitiveType type, Vector3 localPos, Vector3 scale, Material mat) + { + var p = GameObject.CreatePrimitive(type); + Object.Destroy(p.GetComponent()); + p.transform.SetParent(parent.transform, false); + p.transform.localPosition = localPos; + p.transform.localScale = scale; + p.GetComponent().sharedMaterial = mat; + return p; + } + + // Текстура земли: обычная -- трава, скалы, русло; в режиме стройки -- клетки, открытая зона и следующая + void RefreshGround() + { + int n = City3DLayout.Size, px = PixelsPerCell; + bool grid = mode != Mode.View; + var pixels = new Color32[n * px * n * px]; + for (int y = 0; y < n; y++) + for (int x = 0; x < n; x++) + { + CellKind k = map.KindAt(x, y); + Color c = k == CellKind.Water ? new Color(0.3f, 0.45f, 0.7f) + : k == CellKind.Mountain ? new Color(0.5f, 0.48f, 0.45f) + : Color.Lerp(new Color(0.35f, 0.58f, 0.3f), new Color(0.42f, 0.66f, 0.34f), Mathf.PerlinNoise(x * 0.3f, y * 0.3f)); + if (grid && k == CellKind.Ground) + { + int zone = map.ZoneAt(x, y); + if (zone > 0 && zone <= townHallLevel) c = Color.Lerp(c, new Color(0.6f, 0.95f, 0.6f), 0.25f); // открыто + else if (zone == townHallLevel + 1) c = Color.Lerp(c, new Color(0.95f, 0.85f, 0.4f), 0.3f); // откроется следующим + else c *= 0.55f; // закрыто + } + bool open = map.IsOpen(x, y, townHallLevel); + for (int iy = 0; iy < px; iy++) + for (int ix = 0; ix < px; ix++) + { + Color p = c; + if (grid && (ix == 0 || iy == 0)) p *= 0.85f; // линии клеток + // граница открытой зоны -- светлая линия + if (grid && open && (ix == 0 && !map.IsOpen(x - 1, y, townHallLevel) || ix == px - 1 && !map.IsOpen(x + 1, y, townHallLevel) + || iy == 0 && !map.IsOpen(x, y - 1, townHallLevel) || iy == px - 1 && !map.IsOpen(x, y + 1, townHallLevel))) + p = new Color(1f, 1f, 1f); + p.a = 1; + pixels[(y * px + iy) * n * px + x * px + ix] = p; + } + } + groundTexture.SetPixels32(pixels); + groundTexture.Apply(false); + } + + // ---------------- Здания ---------------- + + bool PlaceNew(int type, int x, int y, int rot) + { + var t = Types[type]; + string id = "b_" + nextBuildingId; + PlaceError e = map.Place(id, t.Name, x, y, t.W, t.H, rot, townHallLevel); + if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return false; } + nextBuildingId++; + buildingType[id] = type; + buildingViews[id] = CreateBuildingView(id, type); + UpdateBuildingView(id); + UpdateGrass(); + return true; + } + + GameObject CreateBuildingView(string id, int type) + { + var t = Types[type]; + var root = new GameObject(id + "_" + t.Name); + root.transform.SetParent(transform, false); + var body = GameObject.CreatePrimitive(PrimitiveType.Cube); + body.name = "Body"; + body.transform.SetParent(root.transform, false); + body.GetComponent().sharedMaterial = Mat(t.Color); + var door = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.25f, 0.18f, 0.12f))); + door.name = "Door"; // показывает, куда повёрнуто здание + var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.6f, 0.57f, 0.5f))); + foundation.name = "Foundation"; + + var labelGo = new GameObject("Label"); + labelGo.transform.SetParent(root.transform, false); + var label = labelGo.AddComponent(); + label.text = t.Name; + label.fontSize = 4; + label.alignment = TextAlignmentOptions.Center; + label.color = Color.white; + label.outlineWidth = 0.2f; + label.outlineColor = Color.black; + label.rectTransform.sizeDelta = new Vector2(8, 2); + return root; + } + + // Положение, поворот, фундамент по данным карты (или призрака при переносе) + void UpdateBuildingView(string id) { if (map.TryGetBuilding(id, out var r)) LayoutView(buildingViews[id], buildingType[id], r.X, r.Y, r.Rotation); } + + void LayoutView(GameObject root, int type, int x, int y, int rot) + { + var t = Types[type]; + bool swap = rot == 90 || rot == 270; + int w = swap ? t.H : t.W, h = swap ? t.W : t.H; + int min = int.MaxValue, max = int.MinValue; + for (int cy = y; cy < y + h; cy++) + for (int cx = x; cx < x + w; cx++) { int v = map.HeightAt(cx, cy); min = Mathf.Min(min, v); max = Mathf.Max(max, v); } + if (min == int.MaxValue) { min = 0; max = 0; } + float top = max / 1000f, bottom = min / 1000f - 0.3f; + + root.transform.position = new Vector3(x + w / 2f, top, y + h / 2f); + var body = root.transform.Find("Body"); + body.localScale = new Vector3(w - 0.3f, t.Height, h - 0.3f); + body.localPosition = new Vector3(0, t.Height / 2f, 0); + var foundation = root.transform.Find("Foundation"); + foundation.localScale = new Vector3(w - 0.1f, top - bottom, h - 0.1f); + foundation.localPosition = new Vector3(0, (bottom - top) / 2f, 0); + + // дверь -- на «переднюю» сторону: 0 -- юг, 90 -- запад, 180 -- север, 270 -- восток + var door = root.transform.Find("Door"); + Vector3 dir = Quaternion.Euler(0, -rot, 0) * Vector3.back; + float halfDepth = (rot == 0 || rot == 180 ? h : w) / 2f - 0.15f; + door.localPosition = dir * halfDepth + Vector3.up * 0.4f; + door.localScale = rot == 0 || rot == 180 ? new Vector3(0.6f, 0.8f, 0.1f) : new Vector3(0.1f, 0.8f, 0.6f); + + var label = root.transform.Find("Label"); + label.localPosition = new Vector3(0, t.Height + 0.8f, 0); + } + + void UpdateGrass() + { + for (int i = 0; i < grassViews.Count; i++) + { + Vector2 p = layout.Grass[i]; + bool hidden = map.BuildingAt((int)p.x, (int)p.y) != null; + // и под призраком при переносе: прежнее место -- трава вернётся только после подтверждения + if (!hidden && mode == Mode.Move && movingId != null && map.TryGetBuilding(movingId, out var r)) + hidden = (int)p.x >= r.X && (int)p.x < r.X + r.W && (int)p.y >= r.Y && (int)p.y < r.Y + r.H; + grassViews[i].SetActive(!hidden); + } + } + + // ---------------- Призрак: новое здание или перенос ---------------- + + void StartPlacing(int type) + { + CancelGhost(); + mode = Mode.Place; + placingType = type; + var t = Types[type]; + ghost = CreateBuildingView("ghost", type); + ghostRot = 0; + Vector3 c = cameraRig.target; + ghostX = Mathf.Clamp(Mathf.RoundToInt(c.x - t.W / 2f), 0, City3DLayout.Size - t.W); + ghostY = Mathf.Clamp(Mathf.RoundToInt(c.z - t.H / 2f), 0, City3DLayout.Size - t.H); + EnsureFootprint(); + RefreshGround(); + UpdateGhost(); + } + + void StartMoving(string id) + { + if (!map.TryGetBuilding(id, out var r)) return; + CancelGhost(); + mode = Mode.Move; + movingId = id; + moveOriginal = r; + ghostX = r.X; ghostY = r.Y; ghostRot = r.Rotation; + ghost = buildingViews[id]; // переносится ТО ЖЕ здание -- новое не создаётся + EnsureFootprint(); + RefreshGround(); + UpdateGhost(); + } + + void EnsureFootprint() + { + if (footprint != null) return; + footprint = GameObject.CreatePrimitive(PrimitiveType.Cube); + footprint.name = "Footprint"; + Destroy(footprint.GetComponent()); + footprint.transform.SetParent(transform, false); + } + + int GhostType => mode == Mode.Move ? buildingType[movingId] : placingType; + + void UpdateGhost() + { + if (ghost == null) return; + var t = Types[GhostType]; + ghostError = map.Validate(ghostX, ghostY, t.W, t.H, ghostRot, townHallLevel, mode == Mode.Move ? movingId : null, out ghostFoundation); + LayoutView(ghost, GhostType, ghostX, ghostY, ghostRot); + + bool swap = ghostRot == 90 || ghostRot == 270; + int w = swap ? t.H : t.W, h = swap ? t.W : t.H; + float top = ghost.transform.position.y; + footprint.transform.position = new Vector3(ghostX + w / 2f, top + 0.03f, ghostY + h / 2f); + footprint.transform.localScale = new Vector3(w, 0.06f, h); + if (okMaterial == null) { okMaterial = Mat(new Color(0.3f, 0.95f, 0.35f)); badMaterial = Mat(new Color(0.95f, 0.25f, 0.2f)); } + footprint.GetComponent().sharedMaterial = ghostError == PlaceError.None ? okMaterial : badMaterial; + UpdateGrass(); + } + + void ConfirmGhost() + { + if (ghostError != PlaceError.None) { Say(SettlementMap.ErrorText(ghostError)); return; } + if (mode == Mode.Place) + { + int type = placingType; + int x = ghostX, y = ghostY, rot = ghostRot; + CancelGhost(); + if (PlaceNew(type, x, y, rot)) Say(Types[type].Name + " построено (прототип: без цены и таймера)"); + } + else if (mode == Mode.Move) + { + // Подтверждение -- одно изменение: позиция и поворот того же здания; ошибка -- всё как было + PlaceError e = map.Move(movingId, ghostX, ghostY, ghostRot, townHallLevel); + string id = movingId; + if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return; } + movingId = null; + ghost = null; + mode = Mode.Edit; + UpdateBuildingView(id); + Say("Перенесено. Здание то же: уровень, рабочие и таймеры не меняются"); + FinishGhost(); + } + } + + // Отмена: новое -- не создаётся; перенос -- здание возвращается на последнее подтверждённое место + void CancelGhost() + { + if (mode == Mode.Place && ghost != null) Destroy(ghost); + if (mode == Mode.Move && movingId != null) UpdateBuildingView(movingId); + bool wasMove = mode == Mode.Move; + ghost = null; + movingId = null; + placingType = -1; + mode = wasMove ? Mode.Edit : Mode.View; + FinishGhost(); + } + + void FinishGhost() + { + if (footprint != null) { Destroy(footprint); footprint = null; } + draggingGhost = false; + cameraRig.AllowPan = true; + UpdateGrass(); + RefreshGround(); + } + + // ---------------- Ввод ---------------- + + void Update() + { + if (cam == null) return; + bool down, held, up; + Vector3 pos; + if (Input.touchCount > 0) + { + if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; return; } // два пальца -- зум камеры + Touch touch = Input.GetTouch(0); + pos = touch.position; + down = touch.phase == TouchPhase.Began; + up = touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled; + held = !down && !up; + } + else + { + pos = Input.mousePosition; + down = Input.GetMouseButtonDown(0); + up = Input.GetMouseButtonUp(0); + held = Input.GetMouseButton(0) && !down; + } + + if (down) + { + pointerOverGui = OverGui(pos); + if (pointerOverGui) return; + pointerDown = pos; + pointerMoved = false; + draggingGhost = (mode == Mode.Place || mode == Mode.Move) && CellUnder(pos, true, out int cx, out int cy) && InsideGhost(cx, cy); + cameraRig.AllowPan = !draggingGhost; + cameraRig.BeginPan(pos); + } + else if (held && !pointerOverGui) + { + if ((pos - pointerDown).magnitude > Screen.height * 0.015f) pointerMoved = true; + if (draggingGhost) DragGhost(pos); + else cameraRig.Pan(pos); + } + else if (up && !pointerOverGui) + { + cameraRig.EndPan(); + if (!pointerMoved && !draggingGhost) Tap(pos); + draggingGhost = false; + cameraRig.AllowPan = true; + } + } + + void DragGhost(Vector3 pos) + { + if (!CellUnder(pos, true, out int cx, out int cy)) return; + var t = Types[GhostType]; + bool swap = ghostRot == 90 || ghostRot == 270; + int w = swap ? t.H : t.W, h = swap ? t.W : t.H; + int nx = Mathf.Clamp(cx - w / 2, 0, City3DLayout.Size - w), ny = Mathf.Clamp(cy - h / 2, 0, City3DLayout.Size - h); + if (nx == ghostX && ny == ghostY) return; + ghostX = nx; ghostY = ny; + UpdateGhost(); + } + + bool InsideGhost(int cx, int cy) + { + if (ghost == null) return false; + var t = Types[GhostType]; + bool swap = ghostRot == 90 || ghostRot == 270; + int w = swap ? t.H : t.W, h = swap ? t.W : t.H; + return cx >= ghostX - 1 && cx < ghostX + w + 1 && cy >= ghostY - 1 && cy < ghostY + h + 1; // с запасом на палец + } + + void Tap(Vector3 pos) + { + if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return; + if (mode == Mode.Place || mode == Mode.Move) + { + // тап по земле -- переставить призрак туда + DragGhost(pos); + return; + } + string building = map.BuildingAt(cx, cy); + if (mode == Mode.Edit && building != null) { StartMoving(building); return; } + selectedBuilding = building; + selectedObstacle = building == null ? map.ObstacleAt(cx, cy) : null; + } + + // Клетка под пальцем. groundOnly -- только земля (перетаскивание призрака); иначе здание под пальцем -- его клетка + bool CellUnder(Vector3 screen, bool groundOnly, out int x, out int y) + { + x = y = -1; + Ray ray = cam.ScreenPointToRay(screen); + var hits = Physics.RaycastAll(ray, 500f); + System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); + foreach (var hit in hits) + { + Transform root = hit.collider.transform.parent; + if (!groundOnly && root != null) + foreach (var pair in buildingViews) + if (pair.Value != null && pair.Value.transform == root && map.TryGetBuilding(pair.Key, out var r)) + { + x = r.X; y = r.Y; + return true; + } + if (hit.collider.gameObject.name != "Ground") continue; + x = Mathf.FloorToInt(hit.point.x); + y = Mathf.FloorToInt(hit.point.z); + return map.InMap(x, y); + } + return false; + } + + // Подписи зданий всегда смотрят в камеру + void LateUpdate() + { + if (cam == null) return; + foreach (var v in buildingViews.Values) + if (v != null) { var l = v.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; } + if (ghost != null) { var l = ghost.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; } + } + + void ChangeTownHall(int delta) + { + int before = townHallLevel; + townHallLevel = Mathf.Clamp(townHallLevel + delta, 1, 5); + if (townHallLevel == before) return; + RefreshGround(); + if (ghost != null) UpdateGhost(); + if (townHallLevel > before) + { + // Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах + int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2; + cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2)); + Say($"Ратуша {townHallLevel} ур.: открыта новая зона"); + } + } + + void Say(string text) + { + message = text; + messageUntil = Time.unscaledTime + 4f; + } + + // ---------------- Кнопки (временные, IMGUI) ---------------- + + readonly List guiRects = new List(); + float guiScale = 1f; + GUIStyle button, label, box; + + bool OverGui(Vector3 screen) + { + var p = new Vector2(screen.x / guiScale, (Screen.height - screen.y) / guiScale); + foreach (var r in guiRects) if (r.Contains(p)) return true; + return false; + } + + bool Button(Rect r, string text) + { + guiRects.Add(r); + return GUI.Button(r, text, button); + } + + void Box(Rect r, string text) + { + guiRects.Add(r); + GUI.Box(r, text, box); + } + + void OnGUI() + { + guiScale = Screen.height / 720f; + GUI.matrix = Matrix4x4.Scale(new Vector3(guiScale, guiScale, 1)); + float w = Screen.width / guiScale, h = 720f; + if (button == null) + { + button = new GUIStyle(GUI.skin.button) { fontSize = 20, wordWrap = true }; + label = new GUIStyle(GUI.skin.label) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter }; + label.normal.textColor = Color.white; + box = new GUIStyle(GUI.skin.box) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter }; + box.normal.textColor = Color.white; + } + if (Event.current.type == EventType.Layout) guiRects.Clear(); + + // Верх: режим, Ратуша (тест), редактирование + Box(new Rect(10, 10, 330, 44), $"3D-поселение (прототип) • Ратуша {townHallLevel} ур."); + if (Button(new Rect(350, 10, 60, 44), "−")) ChangeTownHall(-1); + if (Button(new Rect(415, 10, 60, 44), "+")) ChangeTownHall(+1); + if (mode == Mode.View || mode == Mode.Edit) + { + if (Button(new Rect(w - 250, 10, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать")) + { + mode = mode == Mode.Edit ? Mode.View : Mode.Edit; + selectedBuilding = selectedObstacle = null; + RefreshGround(); + if (mode == Mode.Edit) Say("Режим редактирования: нажмите здание, чтобы перенести"); + } + } + + // Слева: что построить + if (mode == Mode.View || mode == Mode.Edit) + { + float y = 64; + for (int i = 1; i < Types.Length; i++) + { + if (Button(new Rect(10, y, 200, 40), $"{Types[i].Name} {Types[i].W}×{Types[i].H}")) StartPlacing(i); + y += 44; + } + } + + // Низ: призрак -- поворот, поставить, отмена + if (mode == Mode.Place || mode == Mode.Move) + { + string status = ghostError == PlaceError.None + ? (ghostFoundation > 0 ? $"Можно ставить (фундамент {ghostFoundation / 1000f:0.0} м)" : "Можно ставить") + : SettlementMap.ErrorText(ghostError); + Box(new Rect(w / 2 - 300, h - 120, 600, 44), (mode == Mode.Move ? "Перенос: " : Types[GhostType].Name + ": ") + status); + if (Button(new Rect(w / 2 - 300, h - 70, 190, 60), "Повернуть 90°")) { ghostRot = (ghostRot + 90) % 360; UpdateGhost(); } + if (Button(new Rect(w / 2 - 95, h - 70, 190, 60), mode == Mode.Move ? "Подтвердить" : "Поставить")) ConfirmGhost(); + if (Button(new Rect(w / 2 + 110, h - 70, 190, 60), "Отмена")) CancelGhost(); + } + + // Меню здания: перенос + if (mode == Mode.View && selectedBuilding != null && map.TryGetBuilding(selectedBuilding, out var b)) + { + Box(new Rect(w / 2 - 250, h - 120, 500, 44), $"{b.Type} • {b.W}×{b.H} клеток • поворот {b.Rotation}°"); + if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Переместить")) { string id = selectedBuilding; selectedBuilding = null; StartMoving(id); } + if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedBuilding = null; + } + + // Меню дерева / валуна: расчистка без цены и ожидания + if (mode == Mode.View && selectedObstacle != null) + { + bool tree = selectedObstacle.StartsWith("tree"); + Box(new Rect(w / 2 - 250, h - 120, 500, 44), tree ? "Дерево" : "Валун"); + if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Расчистить")) + { + if (map.ClearObstacle(selectedObstacle) && obstacleViews.TryGetValue(selectedObstacle, out var view)) + { + Destroy(view); + Say((tree ? "Дерево" : "Валун") + " расчищено. Награда " + (tree ? "деревом" : "камнем") + + " -- после подключения сохранений (этап III)"); + } + selectedObstacle = null; + } + if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedObstacle = null; + } + + if (Time.unscaledTime < messageUntil) + GUI.Label(new Rect(w / 2 - 350, 64, 700, 60), message, label); + } +} diff --git a/Assets/Scripts/City3D/City3DController.cs.meta b/Assets/Scripts/City3D/City3DController.cs.meta new file mode 100644 index 0000000..484ca17 --- /dev/null +++ b/Assets/Scripts/City3D/City3DController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 44d822c903694362bef912c055adda5b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/City3D/City3DLayout.cs b/Assets/Scripts/City3D/City3DLayout.cs new file mode 100644 index 0000000..f5979ac --- /dev/null +++ b/Assets/Scripts/City3D/City3DLayout.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Bogatyri.Settlement.Placement; +using UnityEngine; + +// ТЕСТОВЫЙ УЧАСТОК 3D-ПОСЕЛЕНИЯ (ТЗ «Система строительства поселения» от 04.11.2026, этап I, прототип). +// Холмы, река, скалы, деревья и валуны строятся из seed -- одинаково при каждом запуске. +// Зоны стройки открываются уровнем Ратуши квадратами от центра (ТЕСТ, число и пороги зон -- на согласование). +// Все числа здесь -- предложение для прототипа, не утверждённый баланс. +public sealed class City3DLayout +{ + public const int Size = 48; // клеток по каждой стороне + public const int Seed = 4112026; + + public struct Obstacle + { + public string Id; + public bool IsTree; // дерево (иначе валун) + public List<(int x, int y)> Cells; + } + + public SettlementMap Map; + public readonly List Obstacles = new List(); + public readonly List Grass = new List(); // мелкий декор: скрывается под зданиями + + // Сколько клеток от центра открывает уровень Ратуши: 1 -- 16 x 16, 2 -- 24 x 24, ... 5 -- весь участок + public static int ZoneHalfSize(int townHallLevel) => Mathf.Min(Size / 2, 8 + 4 * (Mathf.Max(1, townHallLevel) - 1)); + + // Высота земли в точке (мировые единицы, клетка = 1) + public static float HeightAt(float x, float z) + { + float cx = x - Size / 2f, cz = z - Size / 2f; + float fromCenter = Mathf.Sqrt(cx * cx + cz * cz) / (Size / 2f); + // Холмы: в центре почти ровно, к краям -- выше и круче + float hills = (Mathf.Sin(x * 0.27f) * 0.5f + Mathf.Cos(z * 0.21f) * 0.45f + Mathf.PerlinNoise(x * 0.11f, z * 0.11f) * 0.9f) + * Mathf.Lerp(0.25f, 1.6f, Mathf.Clamp01(fromCenter)); + if (IsMountain(x, z)) hills += 2.5f + Mathf.PerlinNoise(x * 0.3f, z * 0.3f) * 2f; + if (IsRiver(x, z)) hills = -0.5f; + return hills; + } + + static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f); + static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f; + static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f; + + public static City3DLayout Create(PlacementRules rules = null) + { + var layout = new City3DLayout { Map = new SettlementMap(Size, Size, rules) }; + var map = layout.Map; + int c = Size / 2; + + for (int y = 0; y < Size; y++) + for (int x = 0; x < Size; x++) + { + float px = x + 0.5f, pz = y + 0.5f; + CellKind kind = IsRiver(px, pz) ? CellKind.Water : IsMountain(px, pz) ? CellKind.Mountain : CellKind.Ground; + float dist = Mathf.Max(Mathf.Abs(px - c), Mathf.Abs(pz - c)); // квадратное кольцо от центра + int zone = 0; + for (int lvl = 1; lvl <= 5; lvl++) if (dist < ZoneHalfSize(lvl)) { zone = lvl; break; } + map.SetCell(x, y, kind, Mathf.RoundToInt(HeightAt(px, pz) * 1000f), zone); + } + + // Деревья и валуны: не в центральной поляне 10 x 10 (там Ратуша), не на воде и скалах + var rng = new System.Random(Seed); + var taken = new HashSet(); + bool Free(int x, int y) => map.InMap(x, y) && map.KindAt(x, y) == CellKind.Ground && !taken.Contains(y * Size + x) + && (Math.Abs(x - c) > 5 || Math.Abs(y - c) > 5); + int trees = 0, rocks = 0; + for (int attempt = 0; attempt < 4000 && trees < 90; attempt++) + { + int x = rng.Next(Size), y = rng.Next(Size); + if (!Free(x, y)) continue; + taken.Add(y * Size + x); + layout.Obstacles.Add(new Obstacle { Id = "tree_" + (++trees), IsTree = true, Cells = new List<(int, int)> { (x, y) } }); + } + for (int attempt = 0; attempt < 4000 && rocks < 22; attempt++) + { + int x = rng.Next(Size - 1), y = rng.Next(Size - 1); + bool big = rng.Next(3) == 0; // каждый третий валун -- 2 x 2 + var cells = big ? new List<(int, int)> { (x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1) } : new List<(int, int)> { (x, y) }; + bool ok = true; + foreach (var cell in cells) if (!Free(cell.Item1, cell.Item2)) { ok = false; break; } + if (!ok) continue; + foreach (var cell in cells) taken.Add(cell.Item2 * Size + cell.Item1); + layout.Obstacles.Add(new Obstacle { Id = "rock_" + (++rocks), IsTree = false, Cells = cells }); + } + foreach (var o in layout.Obstacles) map.AddObstacle(o.Id, o.Cells); + + // Трава -- только украшение: не мешает стройке, прячется под зданием и возвращается, когда здание ушло + for (int i = 0; i < 260; i++) + { + float x = (float)rng.NextDouble() * Size, z = (float)rng.NextDouble() * Size; + if (map.KindAt((int)x, (int)z) == CellKind.Ground) layout.Grass.Add(new Vector2(x, z)); + } + return layout; + } +} diff --git a/Assets/Scripts/City3D/City3DLayout.cs.meta b/Assets/Scripts/City3D/City3DLayout.cs.meta new file mode 100644 index 0000000..a41f71e --- /dev/null +++ b/Assets/Scripts/City3D/City3DLayout.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31e2f86e3dff430ea8a67cc6f1cb4038 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/Placement.meta b/Assets/Scripts/Modules/Settlement/Placement.meta new file mode 100644 index 0000000..c3e4e39 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Placement.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bd42162aabdf44f5bc5eff56e8ff9ca6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef b/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef new file mode 100644 index 0000000..dff6fbc --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Bogatyri.Settlement.Placement", + "rootNamespace": "", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef.meta b/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef.meta new file mode 100644 index 0000000..4eb2ef4 --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Placement/Bogatyri.Settlement.Placement.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5a79ac8eed504b9eb36740352ba25b73 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs new file mode 100644 index 0000000..8c4a60e --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs @@ -0,0 +1,249 @@ +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) + { + 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(); + } + } + } +} diff --git a/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs.meta b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs.meta new file mode 100644 index 0000000..78540dc --- /dev/null +++ b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 70c26fdf9f764df5977ed79f7457f90e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef index 08a585f..334f44b 100644 --- a/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef +++ b/Assets/Scripts/Modules/Tests/Bogatyri.Tests.asmdef @@ -12,7 +12,8 @@ "Bogatyri.World.Simulation", "Bogatyri.Chat", "Bogatyri.Circle", - "Bogatyri.Intelligence" + "Bogatyri.Intelligence", + "Bogatyri.Settlement.Placement" ], "includePlatforms": [ "Editor" diff --git a/Assets/Scripts/Modules/Tests/PlacementTests.cs b/Assets/Scripts/Modules/Tests/PlacementTests.cs new file mode 100644 index 0000000..759bf6f --- /dev/null +++ b/Assets/Scripts/Modules/Tests/PlacementTests.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using Bogatyri.Settlement.Placement; +using NUnit.Framework; + +// Тесты размещения зданий в 3D-поселении (ТЗ «Система строительства поселения» от 04.11.2026, раздел 11). +// Запуск: Window -> General -> Test Runner -> EditMode -> Run All. +public class PlacementTests +{ + // Карта 20 x 20: всё открыто с Ратуши 1, кроме правой полосы (x >= 15 -- Ратуша 2); + // река по x = 10, скала в (2, 18), дерево "tree_1" в (5, 5), холм справа вверху + static SettlementMap Map() + { + var map = new SettlementMap(20, 20); + for (int y = 0; y < 20; y++) + for (int x = 0; x < 20; x++) + { + CellKind k = x == 10 ? CellKind.Water : x == 2 && y == 18 ? CellKind.Mountain : CellKind.Ground; + int height = x >= 16 && y >= 16 ? (x - 16) * 500 : 0; // склон: 0,5 м на клетку + map.SetCell(x, y, k, height, x >= 15 ? 2 : 1); + } + map.AddObstacle("tree_1", new[] { (5, 5) }); + return map; + } + + [Test] + public void Placement_FlatGround_Ok_AndForbiddenPlaces() + { + var map = Map(); + Assert.AreEqual(PlaceError.None, map.Place("hall", "Ратуша", 1, 1, 3, 3, 0, 1), "01: ровная земля"); + Assert.AreEqual(PlaceError.Water, map.Validate(9, 2, 2, 2, 0, 1, null, out _), "02: река"); + Assert.AreEqual(PlaceError.Mountain, map.Validate(1, 17, 2, 2, 0, 1, null, out _), "02: скала"); + Assert.AreEqual(PlaceError.Obstacle, map.Validate(4, 4, 2, 2, 0, 1, null, out _), "дерево мешает"); + Assert.AreEqual(PlaceError.OutOfMap, map.Validate(19, 19, 2, 2, 0, 1, null, out _), "за краем"); + Assert.AreEqual(PlaceError.ZoneLocked, map.Validate(15, 2, 2, 2, 0, 1, null, out _), "зона Ратуши 2"); + Assert.AreEqual(PlaceError.None, map.Validate(15, 2, 2, 2, 0, 2, null, out _), "11: Ратуша 2 открыла зону"); + } + + [Test] + public void Placement_GapBetweenBuildings() + { + var map = Map(); + map.Place("a", "Дом", 1, 1, 2, 2, 0, 1); + Assert.AreEqual(PlaceError.Occupied, map.Validate(2, 2, 2, 2, 0, 1, null, out _)); + Assert.AreEqual(PlaceError.TooClose, map.Validate(3, 1, 2, 2, 0, 1, null, out _), "03: вплотную -- нарушен отступ"); + Assert.AreEqual(PlaceError.None, map.Validate(4, 1, 2, 2, 0, 1, null, out _), "через клетку -- можно"); + } + + [Test] + public void Placement_Rotation_SwapsFootprint() + { + var map = Map(); + // 4 x 1 у реки: повёрнутое на 90 -- 1 x 4 -- помещается вдоль реки, без поворота -- упирается в неё + Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 0, 1, null, out _)); + foreach (int rot in new[] { 90, 270 }) + Assert.AreEqual(PlaceError.None, map.Validate(9, 12, 4, 1, rot, 1, null, out _), "04: поворот " + rot); + Assert.AreEqual(PlaceError.Water, map.Validate(7, 12, 4, 1, 180, 1, null, out _), "04: 180 -- как 0"); + map.Place("long", "Склад", 9, 12, 4, 1, 90, 1); + Assert.IsTrue(map.TryGetBuilding("long", out var b)); + Assert.AreEqual(1, b.W); + Assert.AreEqual(4, b.H); + Assert.AreEqual("long", map.BuildingAt(9, 15)); + Assert.IsNull(map.BuildingAt(10, 12)); + } + + [Test] + public void Placement_Slope_FoundationOrTooSteep() + { + var map = Map(); + Assert.AreEqual(PlaceError.None, map.Validate(16, 16, 2, 2, 0, 2, null, out int f), "небольшой перепад"); + Assert.AreEqual(500, f, "фундамент 0,5 м"); + Assert.AreEqual(PlaceError.TooSteep, map.Validate(16, 16, 3, 2, 0, 2, null, out _), "1 м -- слишком круто"); + } + + [Test] + public void Placement_Move_IsAtomic_KeepsId() + { + var map = Map(); + map.Place("farm", "Ферма", 1, 1, 3, 3, 0, 1); + map.Place("mine", "Шахта", 12, 1, 2, 2, 0, 1); + + Assert.AreEqual(PlaceError.None, map.Move("farm", 2, 1, 0, 1), "свой прежний след не мешает"); + Assert.IsTrue(map.TryGetBuilding("farm", out var moved)); + Assert.AreEqual(2, moved.X); + + Assert.AreEqual(PlaceError.Water, map.Move("farm", 8, 1, 0, 1), "09: ошибка -- здание на месте"); + map.TryGetBuilding("farm", out var same); + Assert.AreEqual(2, same.X); + Assert.AreEqual(2, map.Buildings.Count, "без дублей"); + + Assert.AreEqual(PlaceError.None, map.Move("mine", 12, 6, 90, 1), "05: шахту можно переносить и поворачивать"); + Assert.AreEqual(PlaceError.OutOfMap, map.Move("nope", 1, 1, 0, 1)); + } + + [Test] + public void Placement_ClearObstacle_OnceOnly_SurvivesReload() + { + var map = Map(); + Assert.IsTrue(map.ClearObstacle("tree_1"), "10: расчищено -- одна награда"); + Assert.IsFalse(map.ClearObstacle("tree_1"), "второй раз -- без награды"); + Assert.IsNull(map.ObstacleAt(5, 5)); + Assert.AreEqual(PlaceError.None, map.Validate(4, 4, 2, 2, 0, 1, null, out _)); + + // Загрузка: расчищенное не возвращается, даже если отмечено раньше, чем объект добавлен на карту + var copy = new SettlementMap(20, 20); + for (int y = 0; y < 20; y++) for (int x = 0; x < 20; x++) copy.SetCell(x, y, CellKind.Ground, 0, 1); + copy.RestoreCleared(new List(map.Cleared)); + copy.AddObstacle("tree_1", new[] { (5, 5) }); + Assert.IsNull(copy.ObstacleAt(5, 5)); + Assert.IsFalse(copy.ClearObstacle("tree_1")); + } + + [Test] + public void Placement_Restore_NoOverlaps() + { + var map = Map(); + Assert.IsTrue(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 1, Y = 1, SizeW = 2, SizeH = 2, Rotation = 0 })); + Assert.IsFalse(map.Restore(new PlacedRect { Id = "b", Type = "Дом", X = 2, Y = 2, SizeW = 2, SizeH = 2, Rotation = 0 }), "12: наложение не восстанавливается"); + Assert.IsFalse(map.Restore(new PlacedRect { Id = "a", Type = "Дом", X = 6, Y = 6, SizeW = 2, SizeH = 2, Rotation = 0 }), "тот же id -- не дубль"); + Assert.AreEqual(270, SettlementMap.NormalizeRotation(-90)); + } +} diff --git a/Assets/Scripts/Modules/Tests/PlacementTests.cs.meta b/Assets/Scripts/Modules/Tests/PlacementTests.cs.meta new file mode 100644 index 0000000..435b96d --- /dev/null +++ b/Assets/Scripts/Modules/Tests/PlacementTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 73f551b6ba7242e5bb6a9b8e983be5fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 3336948..0a508fa 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | 3D-поселение, этап I — прототип на примитивах (ТЗ от 04.11). Обновлён Settlement. | | 04.11.2026 | Claude | Решения по отчёту Саши от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, цена лечения 1 / 1 / 2 / 3 золота. Обновлены Battle, Army. | | 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. | | 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и госпиталь (логика; здание — следующий шаг). Обновлены Battle, Army, Population, World, Core, Settlement. | diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 8281aa7..afff35d 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -69,6 +69,30 @@ В слое приложения остаются `BuildMenuUi.cs`, `Meta/Build/*` (Казармы, `BuildModeController`). +## 3D-поселение (ТЗ «Система строительства поселения» от 04.11.2026) + +Переход на 3D (2.5D: наклонная камера без вращения) идёт **отдельной сценой** `Assets/Scenes/City3D.unity`. Старый 2D-город (MVP1) работает как раньше, пока 3D его не догонит. Создать сцену: **Tools → Bogatyri → 3D-город: создать сцену City3D**, затем Play. + +**Этап I — прототип на примитивах (сделан 04.11.2026):** +- Правила места — без Unity, сборка `Bogatyri.Settlement.Placement` (`SettlementMap`), тесты `PlacementTests` (сценарии ТЗ 01–05, 09–12). + - Невидимая сетка; клетка 1 единица мира. + - Здание — прямоугольник клеток с максимальным габаритом с первого уровня, поворот 0/90/180/270. + - Проверки: край карты, зона по уровню Ратуши, река и скалы (постоянные), деревья и валуны (расчищаются), другие здания, технический отступ 1 клетка, перепад высоты под зданием до 0,7 м (больше — «слишком круто», меньше — фундамент). + - Перенос — то же здание с тем же id, целиком или никак. + - Расчистка — один раз на объект. +- Сцена (`Assets/Scripts/City3D`): местность 48 × 48 с холмами, река, скалы, 90 деревьев, 22 валуна, трава. + - Сетка, открытая зона и следующая зона видны только в режиме стройки и редактирования. + - Новое здание: кнопка типа → куб-призрак тянется пальцем, зелёный или красный с причиной, «Повернуть 90°», «Поставить» / «Отмена». + - Перенос: «Переместить» в меню здания или режим «Редактировать»; «Подтвердить» / «Отмена» возвращает на последнее подтверждённое место. + - Трава прячется под зданием и возвращается, когда его перенесли. + - Камера: перетаскивание, два пальца / колесо, границы карты; при открытии зоны плавно отходит. + - Ратуша −/+ — **только для теста** зон. +- Рендер: в URP добавлен второй рендерер — обычный 3D (`Assets/Settings/Renderer3D.asset`), его использует только камера City3D. 2D-сцены не тронуты. + +**Предложения на согласование (ТЗ, раздел 12), ТЕСТ:** клетка 1 м; отступ 1 клетка; перепад до 0,7 м; зоны — квадраты от центра 16 / 24 / 32 / 40 / 48 клеток для Ратуши 1–5; габариты: Ратуша 4×4, Дом 2×2, Ферма 3×3, Лесопилка и Каменоломня 3×2, Шахта 2×2, Склад и Амбар 2×3, Казармы и Госпиталь 3×3. + +**Дальше:** этап II — стройка через модуль Поселения (цена, таймер по реальному времени, события, сохранение позиции, поворота и id), перенос живых зданий без сброса производства / обучения / лечения; этап III — награда за расчистку, сохранение расчищенного, автодороги, зоны от настоящей Ратуши; этап IV — телефон. Кнопки — временные (IMGUI), окна — с Володей. + ## Решения и план - **Решение команды от 19.10.2026 (этап 2):** Ратуша даёт базовую вместимость жителей, новое здание «Дом» — дополнительные места. @@ -82,6 +106,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude (Саша) | 3D-поселение, этап I (ТЗ от 04.11): правила места `Bogatyri.Settlement.Placement` + тесты, сцена City3D на примитивах (местность, зоны, призрак, поворот, перенос, расчистка, камера), 3D-рендерер для её камеры. Ветка `feature/3d-settlement`. | | 01.11.2026 | Claude (Саша) | Новое здание «Госпиталь» в списке стройки (`Meta/Build/HospitalBootstrap.cs`, как Казармы): уровни I–V сохраняются как у остальных (`ILeveledBuilding`), тап — окно госпиталя. Места — в `HospitalConfig`. Арта нет: копия обычного здания светло-голубого цвета. | | 01.11.2026 | Claude (Саша) | Для госпиталя (решения 31.10, п. 7) в ядре есть контракт `IHospital.Beds` — его будет регистрировать здание госпиталя (уровни I–V, 20–60 мест). Здания пока нет. | | 31.10.2026 | Claude | Ратуша: тап больше не улучшает сразу, а открывает окно Ратуши (событие `TownHall.Tapped`, окно — `App/BuildingWindow`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. |