From a3c4a99a7989129bc21cf938743dddb069a49baf Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 14:50:22 +0100 Subject: [PATCH 1/8] =?UTF-8?q?3D-=D0=BF=D0=BE=D1=81=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D1=8D=D1=82=D0=B0=D0=BF=20I:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=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`). Улучшение — кнопкой в окне, с ценой и тем, что откроется. | From 9a6e5a2e7b24a33acd76f9bbc6f2a5add77eeb74 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 14:55:41 +0100 Subject: [PATCH 2/8] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D1=82=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=20=D0=B2=202.5D=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карта на земле (X/Z): замок, города, лагеря, ресурсные точки, отряды и метки разведки -- примитивы, подписи смотрят в камеру; наклонная перспективная камера с тем же поведением (перетаскивание, щипок, тап); туман -- в текстуре земли; солнце и 3D-рендерер URP для камеры (Map3D). Логика и окна не менялись. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/City3D/City3DController.cs | 1 + .../Scripts/WorldMap/IntelligenceOverlay.cs | 60 ++++------ Assets/Scripts/WorldMap/Map3D.cs | 106 ++++++++++++++++++ Assets/Scripts/WorldMap/Map3D.cs.meta | 11 ++ .../Scripts/WorldMap/MapCameraController.cs | 68 +++++++---- Assets/Scripts/WorldMap/MarchView.cs | 48 ++++---- Assets/Scripts/WorldMap/WorldMapController.cs | 106 +++++++++--------- Docs/Modules/Intelligence.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/World.md | 1 + 10 files changed, 268 insertions(+), 135 deletions(-) create mode 100644 Assets/Scripts/WorldMap/Map3D.cs create mode 100644 Assets/Scripts/WorldMap/Map3D.cs.meta diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index c850503..00b09d2 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -82,6 +82,7 @@ public class City3DController : MonoBehaviour void Start() { cam = Camera.main; + Map3D.UseRenderer3D(cam); // обычный 3D-рендерер URP (на случай, если сцену собрали вручную) 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); diff --git a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs index a7900af..8c5de56 100644 --- a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs +++ b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs @@ -7,14 +7,17 @@ using UnityEngine; // здесь только рисуем то, что он вернул. // // Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто. -// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects). +// 2.5D (04.11.2026): туман рисуется прямо в текстуре земли -- объекты (города, лагеря, точки) стоят над ней и видны всегда, +// как требует решение 31.10, п. 1. hideUnexploredObjects в 3D пока только сильнее затемняет землю. // Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~min–max» // и владельцем, если опознан; вышли из обзора -- бледная метка последнего контакта. public class IntelligenceOverlay : MonoBehaviour { static readonly Color ObservedColor = new Color(0.9f, 0.25f, 0.2f); - static readonly Color GhostColor = new Color(0.9f, 0.25f, 0.2f, 0.35f); - const float MarkerSize = 0.55f; + static readonly Color GhostColor = new Color(0.75f, 0.55f, 0.55f); // бледная -- последний контакт + static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f); + static readonly Color FogColor = new Color(0.04f, 0.055f, 0.08f); + const float MarkerSize = 0.55f, MarkerHeight = 0.5f; const int CellSize = 1000; private Transform mapRoot; @@ -23,17 +26,19 @@ public class IntelligenceOverlay : MonoBehaviour private Color32[] pixels; private int gridW, gridH; private float nextFogRefresh; - private readonly Dictionary markers = new Dictionary(); + private Renderer ground; + private readonly Dictionary markers = new Dictionary(); private readonly HashSet alive = new HashSet(); private readonly List stale = new List(); - public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world) + public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world, Renderer ground) { var go = new GameObject("Intelligence"); go.transform.SetParent(mapRoot, false); var overlay = go.AddComponent(); overlay.mapRoot = mapRoot; overlay.world = world; + overlay.ground = ground; overlay.CreateFog(); return overlay; } @@ -48,16 +53,8 @@ public class IntelligenceOverlay : MonoBehaviour wrapMode = TextureWrapMode.Clamp }; pixels = new Color32[gridW * gridH]; - - var go = new GameObject("Fog"); - go.transform.SetParent(transform, false); - var sr = go.AddComponent(); - // 1 пиксель = 1 клетка карты = 1 единица Unity; центр спрайта -- центр карты - sr.sprite = Sprite.Create(fog, new Rect(0, 0, gridW, gridH), new Vector2(0.5f, 0.5f), 1f); - go.transform.position = new Vector3((gridW * CellSize / 2f - WorldMapLayout.HalfWidth) / 1000f, - (gridH * CellSize / 2f - WorldMapLayout.HalfHeight) / 1000f, 0f); - // Объекты видны -- туман между землёй (-10) и объектами (0+). Скрыты -- поверх объектов, под войсками (30) - sr.sortingOrder = IntelligenceConfig.Load().hideUnexploredObjects ? 20 : -9; + // 1 пиксель = 1 клетка карты; текстура натянута на всю землю (UV 0..1 -- от левого нижнего угла карты) + if (ground != null) ground.sharedMaterial.mainTexture = fog; RefreshFog(); } @@ -89,7 +86,9 @@ public class IntelligenceOverlay : MonoBehaviour int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight; MapVisibility v = intel.GetVisibility(x, y); byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored; - pixels[cy * gridW + cx] = new Color32(10, 14, 20, a); + Color c = Color.Lerp(GroundColor, FogColor, a / 255f); // туман -- в цвете земли + c.a = 1f; + pixels[cy * gridW + cx] = c; } fog.SetPixels32(pixels); fog.Apply(false); @@ -107,9 +106,9 @@ public class IntelligenceOverlay : MonoBehaviour m = CreateMarker(c.ArmyId); markers[c.ArmyId] = m; } - m.sprite.transform.position = new Vector3(c.X / 1000f, c.Y / 1000f, 0f); - m.label.transform.position = m.sprite.transform.position + new Vector3(0, 0.75f, 0); - m.sprite.color = c.Observed ? ObservedColor : GhostColor; + m.sprite.transform.position = new Vector3(c.X / 1000f, MarkerHeight / 2f, c.Y / 1000f); + m.label.transform.position = new Vector3(c.X / 1000f, MarkerHeight + 0.8f, c.Y / 1000f); + m.sprite.sharedMaterial = Map3D.Mat(c.Observed ? ObservedColor : GhostColor); string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия"; m.label.text = c.Observed @@ -128,24 +127,13 @@ public class IntelligenceOverlay : MonoBehaviour } } - (SpriteRenderer, TextMeshPro) CreateMarker(string armyId) + (Renderer, TextMeshPro) CreateMarker(string armyId) { - var go = new GameObject("Contact_" + armyId); - go.transform.SetParent(mapRoot, false); - go.transform.localScale = Vector3.one * MarkerSize; - go.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб-флажок: не путается со своими квадратиками - var sr = go.AddComponent(); - sr.sprite = PrimitiveSprites.Square; - sr.sortingOrder = 32; - - var lgo = new GameObject("ContactLabel_" + armyId); - lgo.transform.SetParent(mapRoot, false); - var label = lgo.AddComponent(); - label.fontSize = 2.6f; - label.alignment = TextAlignmentOptions.Center; - label.rectTransform.sizeDelta = new Vector2(6f, 1.2f); - label.sortingOrder = 33; - return (sr, label); + var r = Map3D.Block(mapRoot, "Contact_" + armyId, PrimitiveType.Cube, ObservedColor, Vector3.zero, Vector2.one * MarkerSize, MarkerHeight); + r.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб-флажок: не путается со своими кубиками + var label = Map3D.Label(mapRoot, "", Vector3.zero, 2.6f); + label.gameObject.name = "ContactLabel_" + armyId; + return (r, label); } // Имя владельца -- по его поселению на карте («npc_3» -> «Велимир») diff --git a/Assets/Scripts/WorldMap/Map3D.cs b/Assets/Scripts/WorldMap/Map3D.cs new file mode 100644 index 0000000..e1640bf --- /dev/null +++ b/Assets/Scripts/WorldMap/Map3D.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.Rendering.Universal; + +// 3D НА ПРИМИТИВАХ: общие помощники для карты мира и 3D-поселения (переход на 2.5D, 04.11.2026). +// Материал берётся у встроенного примитива (шейдер URP по умолчанию точно есть в сборке), цвета кешируются. +// Подписи (TextMeshPro) поворачиваются к камере (FaceCamera). +public static class Map3D +{ + static Material baseMaterial; + static readonly Dictionary materials = new Dictionary(); + + public static Material Mat(Color color) + { + if (baseMaterial == null) + { + var probe = GameObject.CreatePrimitive(PrimitiveType.Cube); + baseMaterial = new Material(probe.GetComponent().sharedMaterial); + Object.Destroy(probe); + materials.Clear(); + } + if (!materials.TryGetValue(color, out var m) || m == null) + { + m = new Material(baseMaterial) { color = color }; + materials[color] = m; + } + return m; + } + + // Объект на земле: основание size.x * size.y (по X и Z), высота height, низ -- на уровне ground.y + public static Renderer Block(Transform parent, string name, PrimitiveType type, Color color, Vector3 ground, Vector2 size, float height, + bool collider = false) + { + var go = GameObject.CreatePrimitive(type); + go.name = name; + if (!collider) Object.Destroy(go.GetComponent()); + go.transform.SetParent(parent, false); + // У цилиндра высота -- 2 единицы при масштабе 1 + float scaleY = type == PrimitiveType.Cylinder || type == PrimitiveType.Capsule ? height / 2f : height; + go.transform.localScale = new Vector3(size.x, scaleY, size.y); + go.transform.position = ground + Vector3.up * height / 2f; + var r = go.GetComponent(); + r.sharedMaterial = Mat(color); + return r; + } + + // Подпись над объектом, всегда лицом к камере + public static TextMeshPro Label(Transform parent, string text, Vector3 position, float fontSize) + { + var go = new GameObject("Label"); + go.transform.SetParent(parent, false); + go.transform.position = position; + var tmp = go.AddComponent(); + tmp.text = text; + tmp.fontSize = fontSize; + tmp.alignment = TextAlignmentOptions.Center; + tmp.rectTransform.sizeDelta = new Vector2(6f, 1.6f); + tmp.color = Color.white; + tmp.outlineWidth = 0.25f; + tmp.outlineColor = Color.black; + go.AddComponent(); + return tmp; + } + + // Камере -- обычный 3D-рендерер URP (второй в списке; добавляет Tools -> Bogatyri -> 3D-город). + // Нет его -- остаётся рендерер по умолчанию, и в лог пишется, что делать. + public static void UseRenderer3D(Camera cam) + { + var pipeline = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + var data = cam != null ? cam.GetUniversalAdditionalCameraData() : null; + if (pipeline == null || data == null) return; + var list = pipeline.rendererDataList; + for (int i = 0; i < list.Length; i++) + if (list[i] is UniversalRendererData) { data.SetRenderer(i); return; } + Debug.LogWarning("[3D] В URP нет обычного 3D-рендерера: выполните Tools -> Bogatyri -> 3D-город: создать сцену City3D (он добавится). Без него 3D рисуется без света."); + } + + // Солнце и общий свет, если в сцене их нет (2D-сцены света не имели) + public static void EnsureSun() + { + foreach (var l in Object.FindObjectsByType(FindObjectsSortMode.None)) + if (l.type == LightType.Directional) return; + var sunGo = new GameObject("Sun"); + var sun = sunGo.AddComponent(); + sun.type = LightType.Directional; + sun.intensity = 1.15f; + 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); + } +} + +// Подпись поворачивается к камере (наклонный вид 2.5D) +public class FaceCamera : MonoBehaviour +{ + static Camera cam; + + void LateUpdate() + { + if (cam == null) cam = Camera.main; + if (cam != null) transform.rotation = cam.transform.rotation; + } +} diff --git a/Assets/Scripts/WorldMap/Map3D.cs.meta b/Assets/Scripts/WorldMap/Map3D.cs.meta new file mode 100644 index 0000000..b3a901d --- /dev/null +++ b/Assets/Scripts/WorldMap/Map3D.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c36fcaa4cddf4c76ad06c759afc8301e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/MapCameraController.cs b/Assets/Scripts/WorldMap/MapCameraController.cs index 31fcc64..c4079ac 100644 --- a/Assets/Scripts/WorldMap/MapCameraController.cs +++ b/Assets/Scripts/WorldMap/MapCameraController.cs @@ -1,15 +1,22 @@ using UnityEngine; using UnityEngine.EventSystems; -// Камера карты мира: перетаскивание (палец/мышь), зум (два пальца/колёсико), границы карты. +// Камера карты мира, 2.5D (переход на 3D, 04.11.2026): наклонный вид под фиксированным углом, без вращения. +// Перетаскивание (палец/мышь) двигает карту, два пальца/колёсико -- приближение, границы карты. // Отличает "тап" от "перетаскивания": тап -- это касание, при котором палец почти не сдвинулся. +// Карта лежит на земле: координаты карты (x, y) -> мир (x, 0, y). [DefaultExecutionOrder(-10)] // считать тап раньше, чем его прочитает WorldMapController [RequireComponent(typeof(Camera))] public class MapCameraController : MonoBehaviour { + [Header("Вид")] + public float pitch = 55f; // наклон, градусы от горизонта + public float yaw = 0f; // север карты -- вверху экрана, как было в 2D + public float distance = 18f; + [Header("Зум")] - public float minOrthoSize = 4f; - public float maxOrthoSize = 16f; + public float minDistance = 7f; + public float maxDistance = 42f; public float mouseZoomSpeed = 2f; [Header("Тап")] @@ -20,6 +27,7 @@ public class MapCameraController : MonoBehaviour [HideInInspector] public Vector2 mapHalfSize = new Vector2(20f, 14f); private Camera cam; + private Vector3 target; // точка на земле, на которую смотрит камера private Vector3 lastPointerPosition; private Vector3 pressPosition; private bool isPressed; @@ -31,9 +39,15 @@ public class MapCameraController : MonoBehaviour void Awake() { cam = GetComponent(); + cam.orthographic = false; + cam.fieldOfView = 40f; + cam.nearClipPlane = 0.3f; + cam.farClipPlane = 300f; + Map3D.UseRenderer3D(cam); + Apply(); } - // Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание) + // Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание). Точка -- на земле (x, 0, z) public bool TryGetTap(out Vector3 worldPosition) { worldPosition = tapWorldPosition; @@ -42,8 +56,9 @@ public class MapCameraController : MonoBehaviour public void CenterOn(Vector3 worldPosition) { - transform.position = new Vector3(worldPosition.x, worldPosition.y, transform.position.z); + target = new Vector3(worldPosition.x, 0f, worldPosition.z); ClampToMap(); + Apply(); } void Update() @@ -54,12 +69,14 @@ public class MapCameraController : MonoBehaviour { HandlePinch(); isPressed = false; // после щипка не засчитываем отпускание как тап + Apply(); return; } HandlePointer(); HandleMouseWheel(); ClampToMap(); + Apply(); } void HandlePointer() @@ -86,10 +103,10 @@ public class MapCameraController : MonoBehaviour if (isDragging) { - // Сдвигаем камеру ровно на столько, на сколько сдвинулся палец в мире -- карта "прилипает" к пальцу - Vector3 previous = cam.ScreenToWorldPoint(lastPointerPosition); - Vector3 current = cam.ScreenToWorldPoint(Input.mousePosition); - transform.position += previous - current; + // Сдвигаем камеру ровно на столько, на сколько сдвинулся палец по земле -- карта "прилипает" к пальцу + target += GroundPoint(lastPointerPosition) - GroundPoint(Input.mousePosition); + ClampToMap(); + Apply(); } lastPointerPosition = Input.mousePosition; } @@ -99,8 +116,7 @@ public class MapCameraController : MonoBehaviour if (!isDragging) { hasTap = true; - tapWorldPosition = cam.ScreenToWorldPoint(Input.mousePosition); - tapWorldPosition.z = 0f; + tapWorldPosition = GroundPoint(Input.mousePosition); } isPressed = false; isDragging = false; @@ -116,7 +132,7 @@ public class MapCameraController : MonoBehaviour float currentDistance = (a.position - b.position).magnitude; if (currentDistance <= 0.01f) return; - cam.orthographicSize = Mathf.Clamp(cam.orthographicSize * previousDistance / currentDistance, minOrthoSize, maxOrthoSize); + distance = Mathf.Clamp(distance * previousDistance / currentDistance, minDistance, maxDistance); ClampToMap(); } @@ -124,22 +140,30 @@ public class MapCameraController : MonoBehaviour { float scroll = Input.GetAxis("Mouse ScrollWheel"); if (scroll == 0f || IsPointerOverUI()) return; - cam.orthographicSize = Mathf.Clamp(cam.orthographicSize - scroll * mouseZoomSpeed * 5f, minOrthoSize, maxOrthoSize); + distance = Mathf.Clamp(distance - scroll * mouseZoomSpeed * 10f, minDistance, maxDistance); } - // Камера не уезжает за край карты (если карта меньше экрана -- центрируем) + // Точка, на которую смотрит камера, не уходит за край карты void ClampToMap() { - float halfViewHeight = cam.orthographicSize; - float halfViewWidth = halfViewHeight * cam.aspect; + target.x = Mathf.Clamp(target.x, -mapHalfSize.x, mapHalfSize.x); + target.z = Mathf.Clamp(target.z, -mapHalfSize.y, mapHalfSize.y); + target.y = 0f; + } - float maxX = Mathf.Max(0f, mapHalfSize.x - halfViewWidth); - float maxY = Mathf.Max(0f, mapHalfSize.y - halfViewHeight); + void Apply() + { + Quaternion rot = Quaternion.Euler(pitch, yaw, 0); + transform.rotation = rot; + transform.position = target - rot * Vector3.forward * distance; + } - Vector3 p = transform.position; - p.x = Mathf.Clamp(p.x, -maxX, maxX); - p.y = Mathf.Clamp(p.y, -maxY, maxY); - transform.position = p; + // Точка на земле (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; } static bool IsPointerOverUI() diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index bd20f7c..a0f22af 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -2,7 +2,7 @@ using Bogatyri.World; using TMPro; using UnityEngine; -// Войско в походе на карте: группа маленьких квадратиков + подпись с числом солдат и таймером. +// Войско в походе на карте: группа маленьких кубиков на земле + подпись с числом солдат и таймером (2.5D, 04.11.2026). // Сам ничего не решает -- позицию и состояние берёт из WorldSimulation. public class MarchView : MonoBehaviour { @@ -16,7 +16,7 @@ public class MarchView : MonoBehaviour public int MarchId { get; private set; } - private SpriteRenderer[] squares; + private Renderer[] squares; private TextMeshPro label; private GameObject routeLine; private MarchState shownState; @@ -36,12 +36,15 @@ public class MarchView : MonoBehaviour { var go = new GameObject("Label"); go.transform.SetParent(transform, false); - go.transform.localPosition = new Vector3(0, 0.75f, 0); + go.transform.localPosition = new Vector3(0, 1.0f, 0); label = go.AddComponent(); label.fontSize = 3f; label.alignment = TextAlignmentOptions.Center; - label.rectTransform.sizeDelta = new Vector2(4f, 0.8f); - label.sortingOrder = 31; + label.rectTransform.sizeDelta = new Vector2(5f, 0.9f); + label.color = Color.white; + label.outlineWidth = 0.25f; + label.outlineColor = Color.black; + go.AddComponent(); } // Перестраиваем квадратики, только когда поменялось число солдат (после боя) @@ -54,7 +57,7 @@ public class MarchView : MonoBehaviour int count = Mathf.Clamp(soldiers, 1, MaxSquares); int rows = Mathf.CeilToInt(count / (float)SquaresPerRow); - squares = new SpriteRenderer[count]; + squares = new Renderer[count]; for (int i = 0; i < count; i++) { @@ -62,19 +65,19 @@ public class MarchView : MonoBehaviour int row = i / SquaresPerRow; int inThisRow = Mathf.Min(SquaresPerRow, count - row * SquaresPerRow); - var go = new GameObject("Soldier"); + var go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = "Soldier"; + Destroy(go.GetComponent()); go.transform.SetParent(transform, false); go.transform.localPosition = new Vector3( (col - (inThisRow - 1) / 2f) * SquareSpacing, - ((rows - 1) / 2f - row) * SquareSpacing, - 0f); + SquareSize / 2f, + ((rows - 1) / 2f - row) * SquareSpacing); go.transform.localScale = Vector3.one * SquareSize; - var sr = go.AddComponent(); - sr.sprite = PrimitiveSprites.Square; - sr.color = color; - sr.sortingOrder = 30; - squares[i] = sr; + var r = go.GetComponent(); + r.sharedMaterial = Map3D.Mat(color); + squares[i] = r; } } @@ -85,16 +88,15 @@ public class MarchView : MonoBehaviour Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId)); Vector3 delta = target - home; - routeLine = new GameObject($"Route_{march.id}"); + // Тонкая полоса на земле от города до цели + routeLine = GameObject.CreatePrimitive(PrimitiveType.Cube); + routeLine.name = $"Route_{march.id}"; + Destroy(routeLine.GetComponent()); routeLine.transform.SetParent(transform.parent, false); - routeLine.transform.position = (home + target) / 2f; - routeLine.transform.localScale = new Vector3(delta.magnitude, 0.08f, 1f); - routeLine.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg); - - var sr = routeLine.AddComponent(); - sr.sprite = PrimitiveSprites.Square; - sr.color = new Color(1f, 1f, 1f, 0.35f); - sr.sortingOrder = -5; + routeLine.transform.position = (home + target) / 2f + Vector3.up * 0.02f; + routeLine.transform.localScale = new Vector3(delta.magnitude, 0.02f, 0.08f); + routeLine.transform.rotation = Quaternion.Euler(0, -Mathf.Atan2(delta.z, delta.x) * Mathf.Rad2Deg, 0); + routeLine.GetComponent().sharedMaterial = Map3D.Mat(new Color(0.85f, 0.85f, 0.8f)); } void OnDestroy() diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index a4e47dc..da02881 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -48,7 +48,10 @@ public class WorldMapController : MonoBehaviour const float CastleSize = 2.2f; const float CitySize = 1.1f; const float CampSize = 0.8f; - const float StartZoom = 8f; + const float StartDistance = 18f; // камера 2.5D: расстояние до земли + + // Высота объектов над землёй (2.5D на примитивах, 04.11.2026) + const float CastleHeight = 1.4f, CityHeight = 0.9f, CampHeight = 0.5f, PointHeight = 0.45f; static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f); static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f); @@ -67,10 +70,10 @@ public class WorldMapController : MonoBehaviour private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>(); // Лагеря меняют вид: разбит -- серый, с таймером до появления - private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>(); + private readonly List<(MapNode node, Renderer sprite, TextMeshPro label)> campViews = new List<(MapNode, Renderer, TextMeshPro)>(); // Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры - private readonly Dictionary pointViews = new Dictionary(); + private readonly Dictionary pointViews = new Dictionary(); private readonly List stalePoints = new List(); const float PointSize = 0.7f; static readonly Color[] PointColors = @@ -93,8 +96,8 @@ public class WorldMapController : MonoBehaviour void Start() { mapRoot = new GameObject("Map").transform; - BuildMap(); - IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки) + Renderer ground = BuildMap(); + IntelligenceOverlay.Create(mapRoot, World, ground); // туман на земле и чужие армии (модуль Разведки) SetUpCamera(); backButton.onClick.AddListener(SceneNavigator.ReturnToCity); @@ -211,11 +214,13 @@ public class WorldMapController : MonoBehaviour // ---------------- КАРТА ---------------- - void BuildMap() + // Карта лежит на земле (2.5D): замок -- стена с башней, свой город -- кубик на белой площадке, + // соседи -- кубики, лагеря -- цилиндры, ресурсные точки -- ромбы. Подписи висят над объектами. + Renderer BuildMap() { float width = WorldMapLayout.HalfWidth / 1000f * 2f; float height = WorldMapLayout.HalfHeight / 1000f * 2f; - CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10); + Renderer ground = CreateGround(width, height); foreach (var node in World.Nodes) { @@ -224,32 +229,50 @@ public class WorldMapController : MonoBehaviour switch (node.type) { case MapNodeType.BossCastle: - CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0); - CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1); - CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f); - garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f))); + Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, CastleColor, pos, Vector2.one * CastleSize, CastleHeight * 0.6f); + Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, new Color(0.3f, 0.08f, 0.08f), pos, Vector2.one * CastleSize * 0.5f, CastleHeight * 1.8f); + Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.1f), 4f); + garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.45f), 3f))); break; case MapNodeType.PlayerCity: - // Белая рамка -- чтобы свой город сразу бросался в глаза - CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0); - CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1); - CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f); + // Белая площадка -- чтобы свой город сразу бросался в глаза + Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.35f), 0.12f); + Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, PlayerCityColor, pos + Vector3.up * 0.12f, Vector2.one * CitySize, CityHeight); + Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 0.75f), 3.2f); break; case MapNodeType.NeighborCity: - CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0); - garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f))); + Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, NeighborCityColor, pos, Vector2.one * CitySize, CityHeight); + garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 0.85f), 2.6f))); break; case MapNodeType.Camp: - // Круг, а не квадрат: лагерь не путается с городом - var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0); - var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f); + // Цилиндр, а не куб: лагерь не путается с городом + var sprite = Map3D.Block(mapRoot, node.id, PrimitiveType.Cylinder, CampColor, pos, Vector2.one * CampSize, CampHeight); + var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight + 0.8f), 2.3f); campViews.Add((node, sprite, label)); break; } } + return ground; + } + + // Земля -- плоскость с текстурой: её раскрашивает туман (IntelligenceOverlay). UV (0,0) -- левый нижний угол карты + Renderer CreateGround(float width, float height) + { + var mesh = new Mesh { name = "WorldMap_Ground" }; + float hx = width / 2f, hz = height / 2f; + mesh.vertices = new[] { new Vector3(-hx, 0, -hz), new Vector3(-hx, 0, hz), new Vector3(hx, 0, hz), new Vector3(hx, 0, -hz) }; + mesh.uv = new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) }; + mesh.triangles = new[] { 0, 1, 2, 0, 2, 3 }; + mesh.RecalculateNormals(); + var go = new GameObject("Ground"); + go.transform.SetParent(mapRoot, false); + go.AddComponent().sharedMesh = mesh; + var r = go.AddComponent(); + r.sharedMaterial = new Material(Map3D.Mat(GroundColor)) { color = Color.white }; + return r; } void SetUpCamera() @@ -263,7 +286,8 @@ public class WorldMapController : MonoBehaviour } mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f); - mapCamera.GetComponent().orthographicSize = StartZoom; + mapCamera.distance = StartDistance; + Map3D.EnsureSun(); // в 2D-сцене света не было CenterOnMyCity(); } @@ -273,33 +297,6 @@ public class WorldMapController : MonoBehaviour if (home != null) mapCamera.CenterOn(ToWorld(home)); } - SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order) - { - var go = new GameObject(name); - go.transform.SetParent(mapRoot, false); - go.transform.position = position; - go.transform.localScale = scale; - var sr = go.AddComponent(); - sr.sprite = sprite; - sr.color = color; - sr.sortingOrder = order; - return sr; - } - - TextMeshPro CreateLabel(string text, Vector3 position, float fontSize) - { - var go = new GameObject("Label"); - go.transform.SetParent(mapRoot, false); - go.transform.position = position; - var tmp = go.AddComponent(); - tmp.text = text; - tmp.fontSize = fontSize; - tmp.alignment = TextAlignmentOptions.Center; - tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f); - tmp.sortingOrder = 10; - return tmp; - } - void RefreshCamps() { if (Time.unscaledTime < nextCampRefresh) return; @@ -315,7 +312,7 @@ public class WorldMapController : MonoBehaviour foreach (var (node, sprite, label) in campViews) { long left = World.CampRespawnInMs(node.id, now); - sprite.color = left > 0 ? CampClearedColor : CampColor; + sprite.sharedMaterial = Map3D.Mat(left > 0 ? CampClearedColor : CampColor); label.text = left > 0 // разбил его сам игрок -- это он знает ? $"{node.displayName}\nразбит, вернётся через {FormatMinutes(left)}" : $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}"; @@ -332,9 +329,9 @@ public class WorldMapController : MonoBehaviour { MapNode node = World.GetNode(p.id); Vector3 pos = ToWorld(node); - var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0); - sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем - var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f); + var sprite = Map3D.Block(mapRoot, p.id, PrimitiveType.Cube, PointColors[p.kind], pos, Vector2.one * PointSize, PointHeight); + sprite.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб -- не путается с городом и лагерем + var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (PointHeight + 0.75f), 2.2f); view = (sprite, label); pointViews[p.id] = view; } @@ -384,9 +381,10 @@ public class WorldMapController : MonoBehaviour return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек"; } + // Координаты карты (x, y) -> мир на земле (x, 0, y). 1000 милли-единиц = 1 клетка public static Vector3 ToWorld(MapNode node) { - return new Vector3(node.x / 1000f, node.y / 1000f, 0f); + return new Vector3(node.x / 1000f, 0f, node.y / 1000f); } // ---------------- ВЫБОР ЦЕЛИ ---------------- @@ -419,7 +417,7 @@ public class WorldMapController : MonoBehaviour : node.type == MapNodeType.ResourcePoint ? PointSize : CitySize; float halfSize = size / 2f + 0.2f; Vector3 pos = ToWorld(node); - if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node; + if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.z - pos.z) <= halfSize) return node; } return null; } diff --git a/Docs/Modules/Intelligence.md b/Docs/Modules/Intelligence.md index 573d0a5..54ade32 100644 --- a/Docs/Modules/Intelligence.md +++ b/Docs/Modules/Intelligence.md @@ -110,6 +110,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. | | 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. | | 01.11.2026 | Claude | Решения 31.10, п. 1: объекты видны всегда, а гарнизоны, запас и срок точек, прогноз боя и сбора — только в зоне обзора (`WorldMapController`). | | 31.10.2026 | Claude | Исправлено: после перезапуска или перемотки обратный путь войска не открывал туман (до расчёта боя Разведка считала, что войско стоит у цели). Мир досчитывается по событиям (`WorldSimulation.AdvanceInSteps`), Разведка — до каждого из них. Туман темнее. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 0a508fa..909d336 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 | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. | | 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. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 6ba116e..75898e6 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -158,6 +158,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. | | 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. | | 01.11.2026 | Claude | Решения 31.10, п. 3: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. | From 9a91926b2c121141632153c20fbbb5d38a42eae1 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:03:16 +0100 Subject: [PATCH 3/8] =?UTF-8?q?3D-=D1=81=D1=86=D0=B5=D0=BD=D1=8B=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=B8=D0=B2=D0=B5=D0=B5:=20=D1=86=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=B0,=20=D1=81=D0=B2=D0=B5=D1=82,=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B1=D0=BE,=20=D0=B4=D1=8B=D0=BC=D0=BA=D0=B0;=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D0=BA=D0=B8=20=D1=81=20=D0=BA=D1=80=D1=8B=D1=88?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8,=20=D0=B7=D0=B0=D0=BC=D0=BE=D0=BA,=20=D1=88?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D1=8B,=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8F,=20=D0=BA=D0=B0=D0=BC=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цвет -- в _BaseColor (URP), раньше всё было белым. Настройка 3D-рендерера добавляет его в URP Asset каждого уровня качества (в проекте URP задан только в Quality) + отдельная команда меню. Карта мира: замок со стенами и башнями, города -- домики, лагеря -- шатры с костром, кристаллы точек, рощи. 3D-поселение: здания с двускатными крышами и приметами, ели и лиственные, песчаные берега, снег на скалах. Co-Authored-By: Claude Opus 5.5 --- Assets/Editor/City3DSetup.cs | 49 +- Assets/Scenes/City3D.unity | 431 ++++++++++++++++++ Assets/Scenes/City3D.unity.meta | 7 + Assets/Scripts/City3D/City3DController.cs | 144 +++--- .../Scripts/WorldMap/IntelligenceOverlay.cs | 10 +- Assets/Scripts/WorldMap/Map3D.cs | 208 ++++++++- Assets/Scripts/WorldMap/WorldMapController.cs | 118 ++++- 7 files changed, 866 insertions(+), 101 deletions(-) create mode 100644 Assets/Scenes/City3D.unity create mode 100644 Assets/Scenes/City3D.unity.meta diff --git a/Assets/Editor/City3DSetup.cs b/Assets/Editor/City3DSetup.cs index a70690e..4e6cd66 100644 --- a/Assets/Editor/City3DSetup.cs +++ b/Assets/Editor/City3DSetup.cs @@ -57,13 +57,25 @@ public static class City3DSetup Debug.Log($"[3D-ГОРОД] Сцена создана: {ScenePath}. 3D-рендерер: индекс {rendererIndex}. Нажмите Play."); } - // Второй рендерер URP -- обычный (3D). Возвращает его индекс в списке рендереров (или -1) + // Второй рендерер URP -- обычный (3D). Добавляется в URP Asset из Graphics Settings и из КАЖДОГО уровня качества + // (в этом проекте URP задан только в Quality). Возвращает индекс рендерера в списке (или -1) + [MenuItem("Tools/Bogatyri/3D: добавить 3D-рендерер в URP")] + static void AddRendererMenu() + { + int index = EnsureRenderer3D(); + Debug.Log(index >= 0 ? $"[3D] 3D-рендерер подключён (индекс {index}). Можно запускать карту мира и City3D." + : "[3D] URP Asset не найден ни в Graphics, ни в Quality Settings."); + } + static int EnsureRenderer3D() { - var pipeline = GraphicsSettings.defaultRenderPipeline as UniversalRenderPipelineAsset; - if (pipeline == null) + var pipelines = new System.Collections.Generic.List(); + void Add(RenderPipelineAsset a) { if (a is UniversalRenderPipelineAsset u && !pipelines.Contains(u)) pipelines.Add(u); } + Add(GraphicsSettings.defaultRenderPipeline); + for (int q = 0; q < QualitySettings.names.Length; q++) Add(QualitySettings.GetRenderPipelineAssetAt(q)); + if (pipelines.Count == 0) { - Debug.LogWarning("[3D-ГОРОД] В Graphics Settings нет URP Asset -- камера будет рисовать рендерером по умолчанию"); + Debug.LogWarning("[3D-ГОРОД] Нет URP Asset ни в Graphics, ни в Quality Settings -- камера будет рисовать рендерером по умолчанию"); return -1; } @@ -75,17 +87,26 @@ public static class City3DSetup 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); + int result = -1; + foreach (var pipeline in pipelines) + { + var so = new SerializedObject(pipeline); + var list = so.FindProperty("m_RendererDataList"); + int found = -1; + for (int i = 0; i < list.arraySize; i++) + if (list.GetArrayElementAtIndex(i).objectReferenceValue == renderer) found = i; + if (found < 0) + { + list.arraySize++; + list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = renderer; + so.ApplyModifiedProperties(); + EditorUtility.SetDirty(pipeline); + found = list.arraySize - 1; + } + if (result < 0) result = found; + } AssetDatabase.SaveAssets(); - return list.arraySize - 1; + return result; } static void AddToBuildSettings() diff --git a/Assets/Scenes/City3D.unity b/Assets/Scenes/City3D.unity new file mode 100644 index 0000000..c4fee8e --- /dev/null +++ b/Assets/Scenes/City3D.unity @@ -0,0 +1,431 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.55, g: 0.58, b: 0.62, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 3 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 0 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 2 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 1 + m_PVRFilteringGaussRadiusAO: 1 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &110794169 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 110794174} + - component: {fileID: 110794173} + - component: {fileID: 110794172} + - component: {fileID: 110794171} + - component: {fileID: 110794170} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &110794170 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 110794169} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e8645f0028574904969b5802443f100f, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::City3DCamera + pitch: 52 + yaw: 45 + distance: 26 + minDistance: 8 + maxDistance: 55 + target: {x: 0, y: 0, z: 0} + bounds: + serializedVersion: 2 + x: 0 + y: 0 + width: 48 + height: 48 +--- !u!114 &110794171 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 110794169} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Runtime::UnityEngine.Rendering.Universal.UniversalAdditionalCameraData + m_RenderShadows: 1 + m_RequiresDepthTextureOption: 2 + m_RequiresOpaqueTextureOption: 2 + m_CameraType: 0 + m_Cameras: [] + m_RendererIndex: -1 + m_VolumeLayerMask: + serializedVersion: 2 + m_Bits: 1 + m_VolumeTrigger: {fileID: 0} + m_VolumeFrameworkUpdateModeOption: 2 + m_RenderPostProcessing: 0 + m_Antialiasing: 0 + m_AntialiasingQuality: 2 + m_StopNaN: 0 + m_Dithering: 0 + m_ClearDepth: 1 + m_AllowXRRendering: 1 + m_AllowHDROutput: 1 + m_UseScreenCoordOverride: 0 + m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0} + m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0} + m_RequiresDepthTexture: 0 + m_RequiresColorTexture: 0 + m_TaaSettings: + m_Quality: 3 + m_FrameInfluence: 0.1 + m_JitterScale: 1 + m_MipBias: 0 + m_VarianceClampScale: 0.9 + m_ContrastAdaptiveSharpening: 0 + m_Version: 2 +--- !u!81 &110794172 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 110794169} + m_Enabled: 1 +--- !u!20 &110794173 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 110794169} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 2 + m_BackGroundColor: {r: 0.55, g: 0.72, b: 0.9, a: 1} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 300 + field of view: 40 + orthographic: 0 + orthographic size: 5 + m_Depth: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &110794174 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 110794169} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1225367365 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1225367367} + - component: {fileID: 1225367366} + m_Layer: 0 + m_Name: City3D + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1225367366 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1225367365} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 44d822c903694362bef912c055adda5b, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::City3DController +--- !u!4 &1225367367 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1225367365} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &1935466178 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1935466180} + - component: {fileID: 1935466179} + m_Layer: 0 + m_Name: Sun + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!108 &1935466179 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1935466178} + m_Enabled: 1 + serializedVersion: 13 + m_Type: 1 + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_Intensity: 1.2 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 0.5, y: 0.5} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShapeRadius: 0.025 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &1935466180 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1935466178} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821794, y: -0.23456973, z: 0.10938166, w: 0.8754261} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 110794174} + - {fileID: 1935466180} + - {fileID: 1225367367} diff --git a/Assets/Scenes/City3D.unity.meta b/Assets/Scenes/City3D.unity.meta new file mode 100644 index 0000000..872b8bc --- /dev/null +++ b/Assets/Scenes/City3D.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c1c34719f0605c247a3575d077f3d656 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index 00b09d2..00bde43 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -88,10 +88,7 @@ public class City3DController : MonoBehaviour 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); + Map3D.SetUpLook(cam, 30f, 85f); // солнце, небо, дымка вдали layout = City3DLayout.Create(); map = layout.Map; @@ -105,11 +102,7 @@ public class City3DController : MonoBehaviour RefreshGround(); } - Material Mat(Color color) - { - var m = new Material(baseMaterial) { color = color }; - return m; - } + static Material Mat(Color color) => Map3D.Mat(color); void BuildGround() { @@ -146,9 +139,7 @@ public class City3DController : MonoBehaviour filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp }; - var mat = Mat(Color.white); - mat.mainTexture = groundTexture; - go.AddComponent().sharedMaterial = mat; + go.AddComponent().sharedMaterial = Map3D.TexturedMat(groundTexture); go.AddComponent().sharedMesh = mesh; // Вода -- плоскость над руслом @@ -158,50 +149,35 @@ public class City3DController : MonoBehaviour 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)); + water.GetComponent().sharedMaterial = Mat(new Color(0.3f, 0.58f, 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)); + int i = 0; 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; + var ground = new Vector3(center.x, City3DLayout.HeightAt(center.x, center.y), center.y); + i++; + var view = o.IsTree + ? Map3D.Tree(transform, o.Id, ground, 1.0f + (i * 37 % 10) * 0.05f, i) // ели и лиственные вперемешку + : Map3D.Rock(transform, o.Id, ground, o.Cells.Count > 1 ? 1.7f : 0.85f, i); + obstacleViews[o.Id] = view.gameObject; } } + // Трава кустиками -- только украшение: прячется под зданием и возвращается void BuildGrass() { - var grass = Mat(new Color(0.35f, 0.65f, 0.3f)); - foreach (var p in layout.Grass) + Color[] greens = { new Color(0.3f, 0.6f, 0.25f), new Color(0.38f, 0.66f, 0.28f), new Color(0.45f, 0.68f, 0.3f) }; + for (int i = 0; i < layout.Grass.Count; i++) { - 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; + Vector2 p = layout.Grass[i]; + var g = Map3D.Shape(transform, "Grass", Map3D.Cone, greens[i % greens.Length], + new Vector3(p.x, City3DLayout.HeightAt(p.x, p.y), p.y), Vector2.one * 0.28f, 0.22f + (i % 3) * 0.06f).gameObject; grassViews.Add(g); } } @@ -227,9 +203,13 @@ public class City3DController : MonoBehaviour 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)); + bool shore = k == CellKind.Ground && (map.KindAt(x - 1, y) == CellKind.Water || map.KindAt(x + 1, y) == CellKind.Water + || map.KindAt(x, y - 1) == CellKind.Water || map.KindAt(x, y + 1) == CellKind.Water); + float hm = map.HeightAt(x, y) / 1000f; + Color c = k == CellKind.Water ? new Color(0.32f, 0.5f, 0.72f) + : k == CellKind.Mountain ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((hm - 3.2f) / 1.5f)) // снег на вершинах + : shore ? new Color(0.78f, 0.72f, 0.52f) // песчаный берег + : Color.Lerp(new Color(0.33f, 0.56f, 0.27f), new Color(0.45f, 0.66f, 0.32f), Mathf.PerlinNoise(x * 0.25f, y * 0.25f)); if (grid && k == CellKind.Ground) { int zone = map.ZoneAt(x, y); @@ -271,18 +251,65 @@ public class City3DController : MonoBehaviour return true; } + // Кровля по типу здания + static Color RoofFor(int type) + { + switch (Types[type].Name) + { + case "Ратуша": return new Color(0.65f, 0.15f, 0.12f); + case "Госпиталь": return new Color(0.45f, 0.55f, 0.65f); + case "Казармы": return new Color(0.4f, 0.15f, 0.12f); + case "Каменоломня": + case "Шахта": return new Color(0.35f, 0.35f, 0.38f); + default: return Color.Lerp(Types[type].Color, new Color(0.3f, 0.15f, 0.1f), 0.55f); + } + } + + // Здание на примитивах: стены и двускатная крыша в «единичном» объёме Body (растягивается под габарит и высоту), + // у некоторых -- свои приметы: башня Ратуши, флаг Казарм, крест Госпиталя, тёмный вход Шахты 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))); + var body = new GameObject("Body").transform; + body.SetParent(root.transform, false); + var hit = body.gameObject.AddComponent(); // тап по зданию попадает в него целиком + hit.center = new Vector3(0, 0.5f, 0); + hit.size = Vector3.one; + + Color walls = Color.Lerp(t.Color, Color.white, 0.3f), roofColor = RoofFor(type); + Unit(body, PrimitiveType.Cube, new Vector3(0, 0.31f, 0), new Vector3(1f, 0.62f, 1f), walls); + var top = Map3D.Shape(body, "Roof", Map3D.Roof, roofColor, Vector3.zero, new Vector2(1.08f, 1.12f), 0.38f).transform; + top.localPosition = new Vector3(0, 0.62f, 0); + switch (t.Name) + { + case "Ратуша": + Unit(body, PrimitiveType.Cube, new Vector3(0.3f, 0.7f, 0.3f), new Vector3(0.28f, 1.4f, 0.28f), walls); + var spire = Map3D.Shape(body, "Spire", Map3D.Pyramid, roofColor, Vector3.zero, Vector2.one * 0.4f, 0.4f, 45f).transform; + spire.localPosition = new Vector3(0.3f, 1.4f, 0.3f); + break; + case "Казармы": + Unit(body, PrimitiveType.Cylinder, new Vector3(-0.4f, 0.75f, 0.4f), new Vector3(0.03f, 0.75f, 0.03f), new Color(0.35f, 0.25f, 0.15f)); + Unit(body, PrimitiveType.Cube, new Vector3(-0.32f, 1.35f, 0.4f), new Vector3(0.16f, 0.12f, 0.01f), new Color(0.8f, 0.15f, 0.15f)); + break; + case "Госпиталь": + Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.08f, 0.28f, 0.01f), new Color(0.85f, 0.1f, 0.1f)); + Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.24f, 0.08f, 0.01f), new Color(0.85f, 0.1f, 0.1f)); + break; + case "Шахта": + Unit(body, PrimitiveType.Cube, new Vector3(0, 0.2f, -0.505f), new Vector3(0.35f, 0.4f, 0.02f), new Color(0.1f, 0.1f, 0.12f)); + break; + case "Ферма": + // грядки перед амбаром + for (int i = 0; i < 3; i++) + Unit(body, PrimitiveType.Cube, new Vector3(-0.3f + i * 0.3f, 0.02f, -0.62f), new Vector3(0.22f, 0.04f, 0.2f), new Color(0.55f, 0.75f, 0.25f)); + break; + } + + var door = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.28f, 0.18f, 0.1f))); door.name = "Door"; // показывает, куда повёрнуто здание - var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.6f, 0.57f, 0.5f))); + var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.62f, 0.58f, 0.52f))); foundation.name = "Foundation"; var labelGo = new GameObject("Label"); @@ -298,6 +325,17 @@ public class City3DController : MonoBehaviour return root; } + // Деталь в единичном объёме здания (Body растягивается под габарит и высоту) + static void Unit(Transform body, PrimitiveType type, Vector3 localPos, Vector3 scale, Color color) + { + var p = GameObject.CreatePrimitive(type); + Destroy(p.GetComponent()); + p.transform.SetParent(body, false); + p.transform.localPosition = localPos; + p.transform.localScale = scale; + p.GetComponent().sharedMaterial = Mat(color); + } + // Положение, поворот, фундамент по данным карты (или призрака при переносе) void UpdateBuildingView(string id) { if (map.TryGetBuilding(id, out var r)) LayoutView(buildingViews[id], buildingType[id], r.X, r.Y, r.Rotation); } @@ -313,9 +351,11 @@ public class City3DController : MonoBehaviour float top = max / 1000f, bottom = min / 1000f - 0.3f; root.transform.position = new Vector3(x + w / 2f, top, y + h / 2f); + // Body -- в размерах без поворота и повёрнут сам: конёк крыши и приметы поворачиваются вместе со зданием 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); + body.localScale = new Vector3(t.W - 0.3f, t.Height, t.H - 0.3f); + body.localPosition = Vector3.zero; + body.localRotation = Quaternion.Euler(0, -rot, 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); diff --git a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs index 8c5de56..1b8eaa9 100644 --- a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs +++ b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs @@ -54,7 +54,11 @@ public class IntelligenceOverlay : MonoBehaviour }; pixels = new Color32[gridW * gridH]; // 1 пиксель = 1 клетка карты; текстура натянута на всю землю (UV 0..1 -- от левого нижнего угла карты) - if (ground != null) ground.sharedMaterial.mainTexture = fog; + if (ground != null) + { + if (ground.sharedMaterial.HasProperty("_BaseMap")) ground.sharedMaterial.SetTexture("_BaseMap", fog); + if (ground.sharedMaterial.HasProperty("_MainTex")) ground.sharedMaterial.SetTexture("_MainTex", fog); + } RefreshFog(); } @@ -86,7 +90,9 @@ public class IntelligenceOverlay : MonoBehaviour int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight; MapVisibility v = intel.GetVisibility(x, y); byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored; - Color c = Color.Lerp(GroundColor, FogColor, a / 255f); // туман -- в цвете земли + // трава разных оттенков (пятнами), поверх -- туман + Color grass = Color.Lerp(GroundColor, new Color(0.42f, 0.55f, 0.3f), Mathf.PerlinNoise(cx * 0.35f, cy * 0.35f)); + Color c = Color.Lerp(grass, FogColor, a / 255f); c.a = 1f; pixels[cy * gridW + cx] = c; } diff --git a/Assets/Scripts/WorldMap/Map3D.cs b/Assets/Scripts/WorldMap/Map3D.cs index e1640bf..236d2bb 100644 --- a/Assets/Scripts/WorldMap/Map3D.cs +++ b/Assets/Scripts/WorldMap/Map3D.cs @@ -6,11 +6,15 @@ using UnityEngine.Rendering.Universal; // 3D НА ПРИМИТИВАХ: общие помощники для карты мира и 3D-поселения (переход на 2.5D, 04.11.2026). // Материал берётся у встроенного примитива (шейдер URP по умолчанию точно есть в сборке), цвета кешируются. +// Свои простые формы (конус, пирамида, двускатная крыша) -- для деревьев, шатров, крыш. // Подписи (TextMeshPro) поворачиваются к камере (FaceCamera). public static class Map3D { static Material baseMaterial; static readonly Dictionary materials = new Dictionary(); + static Mesh cone, pyramid, roof; + + // ---------------- Материалы ---------------- public static Material Mat(Color color) { @@ -23,12 +27,32 @@ public static class Map3D } if (!materials.TryGetValue(color, out var m) || m == null) { - m = new Material(baseMaterial) { color = color }; + m = new Material(baseMaterial); + SetColor(m, color); + if (m.HasProperty("_Smoothness")) m.SetFloat("_Smoothness", 0.12f); // матовые, без пластикового блеска materials[color] = m; } return m; } + // URP берёт цвет из _BaseColor, встроенный шейдер -- из _Color: ставим оба + public static void SetColor(Material m, Color color) + { + if (m.HasProperty("_BaseColor")) m.SetColor("_BaseColor", color); + if (m.HasProperty("_Color")) m.SetColor("_Color", color); + } + + // Материал с текстурой (земля): своя копия, не из кеша + public static Material TexturedMat(Texture texture) + { + var m = new Material(Mat(Color.white)); + if (m.HasProperty("_BaseMap")) m.SetTexture("_BaseMap", texture); + if (m.HasProperty("_MainTex")) m.SetTexture("_MainTex", texture); + return m; + } + + // ---------------- Простые формы ---------------- + // Объект на земле: основание size.x * size.y (по X и Z), высота height, низ -- на уровне ground.y public static Renderer Block(Transform parent, string name, PrimitiveType type, Color color, Vector3 ground, Vector2 size, float height, bool collider = false) @@ -46,6 +70,144 @@ public static class Map3D return r; } + // Своя форма (конус, пирамида, крыша): основание size.x * size.y, высота height, низ -- на ground + public static Renderer Shape(Transform parent, string name, Mesh mesh, Color color, Vector3 ground, Vector2 size, float height, float yaw = 0f) + { + var go = new GameObject(name); + go.transform.SetParent(parent, false); + go.transform.position = ground; + go.transform.rotation = Quaternion.Euler(0, yaw, 0); + go.transform.localScale = new Vector3(size.x, height, size.y); + go.AddComponent().sharedMesh = mesh; + var r = go.AddComponent(); + r.sharedMaterial = Mat(color); + return r; + } + + // Конус: основание -- круг диаметром 1 на y = 0, вершина -- (0, 1, 0) + public static Mesh Cone => cone != null ? cone : cone = BuildCone(10); + // Пирамида: основание 1 x 1, вершина -- (0, 1, 0) + public static Mesh Pyramid => pyramid != null ? pyramid : pyramid = BuildCone(4, 45f, 0.7071f); + // Двускатная крыша: основание 1 x 1, конёк вдоль X на высоте 1 + public static Mesh Roof => roof != null ? roof : roof = BuildRoof(); + + static Mesh BuildCone(int sides, float startDeg = 0f, float radius = 0.5f) + { + var v = new List(); + var t = new List(); + for (int i = 0; i < sides; i++) + { + float a0 = (startDeg + 360f * i / sides) * Mathf.Deg2Rad, a1 = (startDeg + 360f * (i + 1) / sides) * Mathf.Deg2Rad; + Vector3 p0 = new Vector3(Mathf.Cos(a0) * radius, 0, Mathf.Sin(a0) * radius); + Vector3 p1 = new Vector3(Mathf.Cos(a1) * radius, 0, Mathf.Sin(a1) * radius); + TwoSided(v, t, p0, Vector3.up, p1); // бок -- свои вершины: грани с резкими нормалями + TwoSided(v, t, p0, p1, Vector3.zero); // дно + } + var mesh = new Mesh { name = "Cone" + sides, vertices = v.ToArray(), triangles = t.ToArray() }; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + // Треугольник с обеих сторон: у каждой стороны свои вершины и своя нормаль + static void TwoSided(List v, List t, Vector3 p, Vector3 q, Vector3 r) + { + int i = v.Count; + v.Add(p); v.Add(q); v.Add(r); + t.Add(i); t.Add(i + 1); t.Add(i + 2); + i = v.Count; + v.Add(p); v.Add(r); v.Add(q); + t.Add(i); t.Add(i + 1); t.Add(i + 2); + } + + static Mesh BuildRoof() + { + Vector3 a = new Vector3(-0.5f, 0, -0.5f), b = new Vector3(0.5f, 0, -0.5f), c = new Vector3(0.5f, 0, 0.5f), d = new Vector3(-0.5f, 0, 0.5f); + Vector3 r0 = new Vector3(-0.5f, 1, 0), r1 = new Vector3(0.5f, 1, 0); + var v = new List(); + var t = new List(); + void Tri(Vector3 p, Vector3 q, Vector3 r) => TwoSided(v, t, p, q, r); + Tri(a, r0, r1); Tri(a, r1, b); // скат к -Z + Tri(c, r1, r0); Tri(c, r0, d); // скат к +Z + Tri(d, r0, a); Tri(b, r1, c); // торцы + Tri(a, b, c); Tri(a, c, d); // дно + var mesh = new Mesh { name = "Roof", vertices = v.ToArray(), triangles = t.ToArray() }; + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + // ---------------- Готовые фигуры ---------------- + + // Домик: стены w x d (по X и Z), высота стен h, двускатная крыша; поворот yaw + public static Transform House(Transform parent, string name, Vector3 ground, float w, float d, float h, Color walls, Color roofColor, float yaw = 0f) + { + var root = new GameObject(name).transform; + root.SetParent(parent, false); + root.position = ground; + root.rotation = Quaternion.Euler(0, yaw, 0); + var body = Block(root, "Walls", PrimitiveType.Cube, walls, ground, new Vector2(w, d), h); + body.transform.localPosition = new Vector3(0, h / 2f, 0); + body.transform.localRotation = Quaternion.identity; + var top = Shape(root, "Roof", Roof, roofColor, ground, new Vector2(w * 1.12f, d * 1.15f), Mathf.Max(0.25f, Mathf.Min(w, d) * 0.55f)); + top.transform.localPosition = new Vector3(0, h, 0); + top.transform.localRotation = Quaternion.identity; + return root; + } + + static readonly Color Trunk = new Color(0.42f, 0.28f, 0.16f); + static readonly Color[] Leaves = { new Color(0.2f, 0.46f, 0.2f), new Color(0.26f, 0.52f, 0.22f), new Color(0.32f, 0.55f, 0.2f) }; + static readonly Color[] Pine = { new Color(0.12f, 0.36f, 0.22f), new Color(0.16f, 0.4f, 0.24f) }; + + // Дерево: ель (конусы) или лиственное (шар). variant -- для разнообразия, scale -- размер + public static Transform Tree(Transform parent, string name, Vector3 ground, float scale, int variant) + { + var root = new GameObject(name).transform; + root.SetParent(parent, false); + root.position = ground; + var trunk = Block(root, "Trunk", PrimitiveType.Cylinder, Trunk, ground, Vector2.one * 0.18f * scale, 0.6f * scale); + if (variant % 3 == 0) + { + var crown = Block(root, "Crown", PrimitiveType.Sphere, Leaves[variant % Leaves.Length], ground + Vector3.up * 0.45f * scale, + new Vector2(1.0f, 0.95f) * scale, 1.05f * scale); + } + else + { + Color c = Pine[variant % Pine.Length]; + Shape(root, "Pine1", Cone, c, ground + Vector3.up * 0.35f * scale, Vector2.one * 1.0f * scale, 1.0f * scale); + Shape(root, "Pine2", Cone, c, ground + Vector3.up * 0.8f * scale, Vector2.one * 0.72f * scale, 0.85f * scale); + } + return root; + } + + static readonly Color[] Stones = { new Color(0.55f, 0.55f, 0.56f), new Color(0.5f, 0.48f, 0.45f), new Color(0.62f, 0.6f, 0.58f) }; + + // Валун: сплюснутый шар, повёрнутый случайно по variant + public static Transform Rock(Transform parent, string name, Vector3 ground, float size, int variant) + { + var root = new GameObject(name).transform; + root.SetParent(parent, false); + root.position = ground; + var r = Block(root, "Stone", PrimitiveType.Sphere, Stones[variant % Stones.Length], ground + Vector3.down * 0.1f * size, + new Vector2(size, size * 0.85f), size * 0.65f); + r.transform.rotation = Quaternion.Euler(variant * 7 % 20, variant * 53 % 360, 0); + if (size > 1.2f) + { + var small = Block(root, "Stone2", PrimitiveType.Sphere, Stones[(variant + 1) % Stones.Length], + ground + new Vector3(size * 0.4f, 0, size * 0.3f), Vector2.one * size * 0.45f, size * 0.35f); + } + return root; + } + + // Флаг на древке + public static void Flag(Transform parent, Vector3 ground, float height, Color color) + { + Block(parent, "Pole", PrimitiveType.Cylinder, new Color(0.35f, 0.25f, 0.15f), ground, Vector2.one * 0.06f, height); + var f = Block(parent, "Flag", PrimitiveType.Cube, color, ground + new Vector3(0.22f, height - 0.32f, 0), new Vector2(0.42f, 0.03f), 0.28f); + } + + // ---------------- Подписи, свет, рендерер ---------------- + // Подпись над объектом, всегда лицом к камере public static TextMeshPro Label(Transform parent, string text, Vector3 position, float fontSize) { @@ -64,11 +226,11 @@ public static class Map3D return tmp; } - // Камере -- обычный 3D-рендерер URP (второй в списке; добавляет Tools -> Bogatyri -> 3D-город). + // Камере -- обычный 3D-рендерер URP (добавляет Tools -> Bogatyri -> 3D-город: создать сцену City3D). // Нет его -- остаётся рендерер по умолчанию, и в лог пишется, что делать. public static void UseRenderer3D(Camera cam) { - var pipeline = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; + var pipeline = (GraphicsSettings.currentRenderPipeline ?? QualitySettings.renderPipeline) as UniversalRenderPipelineAsset; var data = cam != null ? cam.GetUniversalAdditionalCameraData() : null; if (pipeline == null || data == null) return; var list = pipeline.rendererDataList; @@ -77,19 +239,39 @@ public static class Map3D Debug.LogWarning("[3D] В URP нет обычного 3D-рендерера: выполните Tools -> Bogatyri -> 3D-город: создать сцену City3D (он добавится). Без него 3D рисуется без света."); } - // Солнце и общий свет, если в сцене их нет (2D-сцены света не имели) - public static void EnsureSun() + // Солнце, общий свет, небо и дымка вдали (2D-сцены света не имели) + public static void SetUpLook(Camera cam, float fogStart, float fogEnd) { + Light sun = null; foreach (var l in Object.FindObjectsByType(FindObjectsSortMode.None)) - if (l.type == LightType.Directional) return; - var sunGo = new GameObject("Sun"); - var sun = sunGo.AddComponent(); - sun.type = LightType.Directional; - sun.intensity = 1.15f; + if (l.type == LightType.Directional) { sun = l; break; } + if (sun == null) + { + sun = new GameObject("Sun").AddComponent(); + sun.type = LightType.Directional; + } + sun.color = new Color(1f, 0.95f, 0.85f); // тёплое солнце + sun.intensity = 1.25f; 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); + sun.shadowStrength = 0.65f; + sun.transform.rotation = Quaternion.Euler(48f, -35f, 0f); + + RenderSettings.ambientMode = AmbientMode.Trilight; // небо голубое, горизонт светлый, снизу -- тёплая земля + RenderSettings.ambientSkyColor = new Color(0.62f, 0.72f, 0.86f); + RenderSettings.ambientEquatorColor = new Color(0.55f, 0.6f, 0.58f); + RenderSettings.ambientGroundColor = new Color(0.35f, 0.32f, 0.25f); + + Color sky = new Color(0.66f, 0.8f, 0.94f); + RenderSettings.fog = true; + RenderSettings.fogMode = FogMode.Linear; + RenderSettings.fogColor = sky; + RenderSettings.fogStartDistance = fogStart; + RenderSettings.fogEndDistance = fogEnd; + if (cam != null) + { + cam.clearFlags = CameraClearFlags.SolidColor; + cam.backgroundColor = sky; + } } } diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index da02881..6218411 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -214,8 +214,8 @@ public class WorldMapController : MonoBehaviour // ---------------- КАРТА ---------------- - // Карта лежит на земле (2.5D): замок -- стена с башней, свой город -- кубик на белой площадке, - // соседи -- кубики, лагеря -- цилиндры, ресурсные точки -- ромбы. Подписи висят над объектами. + // Карта лежит на земле (2.5D): замок -- стены с башнями, свой город и соседи -- домики на площадке, + // лагеря -- шатры с костром, ресурсные точки -- кристаллы цвета ресурса. Подписи висят над объектами. Renderer BuildMap() { float width = WorldMapLayout.HalfWidth / 1000f * 2f; @@ -225,39 +225,114 @@ public class WorldMapController : MonoBehaviour foreach (var node in World.Nodes) { Vector3 pos = ToWorld(node); + int seed = node.id.GetHashCode() & 0x7fff; switch (node.type) { case MapNodeType.BossCastle: - Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, CastleColor, pos, Vector2.one * CastleSize, CastleHeight * 0.6f); - Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, new Color(0.3f, 0.08f, 0.08f), pos, Vector2.one * CastleSize * 0.5f, CastleHeight * 1.8f); - Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.1f), 4f); - garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.45f), 3f))); + BuildCastle(node, pos); + Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.4f), 4f); + garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.75f), 3f))); break; case MapNodeType.PlayerCity: - // Белая площадка -- чтобы свой город сразу бросался в глаза - Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.35f), 0.12f); - Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, PlayerCityColor, pos + Vector3.up * 0.12f, Vector2.one * CitySize, CityHeight); - Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 0.75f), 3.2f); + // Белая площадка и синие крыши -- чтобы свой город сразу бросался в глаза + Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.45f), 0.08f); + BuildTown(node.id, pos + Vector3.up * 0.08f, new Color(0.92f, 0.88f, 0.78f), PlayerCityColor, seed, 4); + Map3D.Flag(mapRoot, pos + new Vector3(0.55f, 0.08f, 0.55f), 1.5f, new Color(0.95f, 0.8f, 0.2f)); + Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 1.0f), 3.2f); break; case MapNodeType.NeighborCity: - Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, NeighborCityColor, pos, Vector2.one * CitySize, CityHeight); - garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 0.85f), 2.6f))); + Map3D.Block(mapRoot, node.id + "_Yard", PrimitiveType.Cube, new Color(0.62f, 0.55f, 0.42f), pos, Vector2.one * (CitySize + 0.25f), 0.05f); + BuildTown(node.id, pos + Vector3.up * 0.05f, new Color(0.85f, 0.75f, 0.58f), new Color(0.62f, 0.25f, 0.18f), seed, 3); + garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 1.05f), 2.6f))); break; case MapNodeType.Camp: - // Цилиндр, а не куб: лагерь не путается с городом - var sprite = Map3D.Block(mapRoot, node.id, PrimitiveType.Cylinder, CampColor, pos, Vector2.one * CampSize, CampHeight); - var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight + 0.8f), 2.3f); + // Шатры разбойников с костром; цвет главного шатра -- разбит лагерь или нет + var sprite = Map3D.Shape(mapRoot, node.id, Map3D.Cone, CampColor, pos, Vector2.one * CampSize, CampHeight * 1.6f); + Map3D.Shape(mapRoot, node.id + "_Tent2", Map3D.Cone, new Color(0.55f, 0.42f, 0.3f), pos + new Vector3(0.45f, 0, -0.25f), Vector2.one * CampSize * 0.6f, CampHeight * 1.1f); + Map3D.Block(mapRoot, node.id + "_Fire", PrimitiveType.Cube, new Color(1f, 0.55f, 0.15f), pos + new Vector3(-0.35f, 0, -0.35f), Vector2.one * 0.14f, 0.12f); + var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight * 1.6f + 0.85f), 2.3f); campViews.Add((node, sprite, label)); break; } } + BuildScenery(width, height); return ground; } + // Замок Змея Горыныча: квадрат стен, четыре угловые башни с красными крышами, высокая главная башня + void BuildCastle(MapNode node, Vector3 pos) + { + Color stone = new Color(0.5f, 0.45f, 0.42f), dark = new Color(0.32f, 0.28f, 0.27f), roofRed = new Color(0.6f, 0.12f, 0.1f); + float half = CastleSize / 2f, wall = 0.18f, wallH = CastleHeight * 0.55f; + Map3D.Block(mapRoot, node.id + "_WallN", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, half - wall / 2), new Vector2(CastleSize, wall), wallH); + Map3D.Block(mapRoot, node.id + "_WallS", PrimitiveType.Cube, stone, pos + new Vector3(0, 0, -half + wall / 2), new Vector2(CastleSize, wall), wallH); + Map3D.Block(mapRoot, node.id + "_WallE", PrimitiveType.Cube, stone, pos + new Vector3(half - wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH); + Map3D.Block(mapRoot, node.id + "_WallW", PrimitiveType.Cube, stone, pos + new Vector3(-half + wall / 2, 0, 0), new Vector2(wall, CastleSize), wallH); + foreach (var corner in new[] { new Vector3(1, 0, 1), new Vector3(-1, 0, 1), new Vector3(1, 0, -1), new Vector3(-1, 0, -1) }) + { + Vector3 c = pos + corner * (half - 0.12f); + Map3D.Block(mapRoot, node.id + "_Tower", PrimitiveType.Cylinder, stone, c, Vector2.one * 0.42f, CastleHeight); + Map3D.Shape(mapRoot, node.id + "_TowerRoof", Map3D.Cone, roofRed, c + Vector3.up * CastleHeight, Vector2.one * 0.55f, 0.5f); + } + Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, dark, pos, Vector2.one * CastleSize * 0.42f, CastleHeight * 1.8f); + Map3D.Shape(mapRoot, node.id + "_KeepRoof", Map3D.Pyramid, roofRed, pos + Vector3.up * CastleHeight * 1.8f, Vector2.one * CastleSize * 0.5f, 0.7f, 45f); + Map3D.Flag(mapRoot, pos + Vector3.up * (CastleHeight * 1.8f + 0.6f), 0.6f, new Color(0.15f, 0.55f, 0.2f)); + } + + // Городок: несколько домиков разного размера на площадке города + void BuildTown(string id, Vector3 pos, Color walls, Color roofColor, int seed, int houses) + { + var rng = new System.Random(seed); + Vector3[] spots = { new Vector3(-0.28f, 0, -0.22f), new Vector3(0.28f, 0, -0.2f), new Vector3(-0.2f, 0, 0.3f), new Vector3(0.25f, 0, 0.28f) }; + for (int i = 0; i < houses && i < spots.Length; i++) + { + float w = 0.38f + (float)rng.NextDouble() * 0.12f, h = 0.3f + (float)rng.NextDouble() * 0.25f; + Map3D.House(mapRoot, id + "_House" + i, pos + spots[i], w, w * 0.8f, h, walls, roofColor, rng.Next(2) * 90f); + } + } + + // Рощи и камни между объектами -- только украшение (не мешают тапам и походам) + void BuildScenery(float width, float height) + { + var rng = new System.Random(WorldMapLayout.DefaultSeed); + int trees = 0; + for (int attempt = 0; attempt < 3000 && trees < 170; attempt++) + { + float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f); + if (!FarFromNodes(x, z, 1.7f)) continue; + // рощами: рядом с деревом -- ещё 1-2 + int group = 1 + rng.Next(3); + for (int g = 0; g < group; g++) + { + float gx = x + ((float)rng.NextDouble() - 0.5f) * 1.2f, gz = z + ((float)rng.NextDouble() - 0.5f) * 1.2f; + if (!FarFromNodes(gx, gz, 1.4f)) continue; + Map3D.Tree(mapRoot, "Tree", new Vector3(gx, 0, gz), 0.55f + (float)rng.NextDouble() * 0.35f, rng.Next(6)); + trees++; + } + } + for (int i = 0, rocks = 0; i < 600 && rocks < 30; i++) + { + float x = ((float)rng.NextDouble() - 0.5f) * (width - 1f), z = ((float)rng.NextDouble() - 0.5f) * (height - 1f); + if (!FarFromNodes(x, z, 1.5f)) continue; + Map3D.Rock(mapRoot, "Rock", new Vector3(x, 0, z), 0.3f + (float)rng.NextDouble() * 0.35f, rng.Next(9)); + rocks++; + } + } + + bool FarFromNodes(float x, float z, float distance) + { + foreach (var n in World.Nodes) + { + float dx = n.x / 1000f - x, dz = n.y / 1000f - z; + if (dx * dx + dz * dz < distance * distance) return false; + } + return true; + } + // Земля -- плоскость с текстурой: её раскрашивает туман (IntelligenceOverlay). UV (0,0) -- левый нижний угол карты Renderer CreateGround(float width, float height) { @@ -271,7 +346,7 @@ public class WorldMapController : MonoBehaviour go.transform.SetParent(mapRoot, false); go.AddComponent().sharedMesh = mesh; var r = go.AddComponent(); - r.sharedMaterial = new Material(Map3D.Mat(GroundColor)) { color = Color.white }; + r.sharedMaterial = Map3D.TexturedMat(Texture2D.whiteTexture); // текстуру с туманом даст IntelligenceOverlay return r; } @@ -287,7 +362,7 @@ public class WorldMapController : MonoBehaviour mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f); mapCamera.distance = StartDistance; - Map3D.EnsureSun(); // в 2D-сцене света не было + Map3D.SetUpLook(mapCamera.GetComponent(), 22f, 60f); // солнце, небо, дымка вдали (в 2D-сцене света не было) CenterOnMyCity(); } @@ -312,7 +387,7 @@ public class WorldMapController : MonoBehaviour foreach (var (node, sprite, label) in campViews) { long left = World.CampRespawnInMs(node.id, now); - sprite.sharedMaterial = Map3D.Mat(left > 0 ? CampClearedColor : CampColor); + sprite.sharedMaterial = Map3D.Mat(left > 0 ? new Color(0.4f, 0.4f, 0.4f) : CampColor); label.text = left > 0 // разбил его сам игрок -- это он знает ? $"{node.displayName}\nразбит, вернётся через {FormatMinutes(left)}" : $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}"; @@ -329,8 +404,11 @@ public class WorldMapController : MonoBehaviour { MapNode node = World.GetNode(p.id); Vector3 pos = ToWorld(node); - var sprite = Map3D.Block(mapRoot, p.id, PrimitiveType.Cube, PointColors[p.kind], pos, Vector2.one * PointSize, PointHeight); - sprite.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб -- не путается с городом и лагерем + // Кристалл цвета ресурса на камне -- не путается с городом и лагерем + var holder = new GameObject(p.id).transform; // исчезнет точка -- уберём всё вместе + holder.SetParent(mapRoot, false); + Map3D.Rock(holder, "Base", pos, PointSize * 0.9f, p.kind + 3); + var sprite = Map3D.Shape(holder, "Crystal", Map3D.Pyramid, PointColors[p.kind], pos + Vector3.up * 0.1f, Vector2.one * PointSize * 0.75f, PointHeight * 1.8f, 45f); var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (PointHeight + 0.75f), 2.2f); view = (sprite, label); pointViews[p.id] = view; From e69d2bd7db8ad2e73f8f34f13d764def519892bc Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:05:12 +0100 Subject: [PATCH 4/8] =?UTF-8?q?3D:=20=D1=81=D0=B2=D0=BE=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=20=D0=BD=D0=B0=20URP?= =?UTF-8?q?=20Lit=20(Resources/Map3D/Map3DLit)=20--=20=D1=83=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D0=B8=D1=82=D0=B8=D0=B2=D0=BE=D0=B2=20=D0=B2?= =?UTF-8?q?=202D-=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B5=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B9=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=88=D0=B5?= =?UTF-8?q?=D0=B9=D0=B4=D0=B5=D1=80=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=20=D0=B8=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/Map3D.meta | 8 ++++ Assets/Resources/Map3D/Map3DLit.mat | 58 ++++++++++++++++++++++++ Assets/Resources/Map3D/Map3DLit.mat.meta | 8 ++++ Assets/Scripts/WorldMap/Map3D.cs | 17 +++++-- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 Assets/Resources/Map3D.meta create mode 100644 Assets/Resources/Map3D/Map3DLit.mat create mode 100644 Assets/Resources/Map3D/Map3DLit.mat.meta diff --git a/Assets/Resources/Map3D.meta b/Assets/Resources/Map3D.meta new file mode 100644 index 0000000..1f09f54 --- /dev/null +++ b/Assets/Resources/Map3D.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 18e162eeff424f8bb3a71543eafba0c8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Resources/Map3D/Map3DLit.mat b/Assets/Resources/Map3D/Map3DLit.mat new file mode 100644 index 0000000..c00c83e --- /dev/null +++ b/Assets/Resources/Map3D/Map3DLit.mat @@ -0,0 +1,58 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &-7493335797097805923 +MonoBehaviour: + m_ObjectHideFlags: 11 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion + version: 10 +--- !u!21 &2100000 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Map3DLit + m_Shader: {fileID: 4800000, guid: 933532a4fcc9baf4fa0491de14d08ed7, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 1 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: + RenderType: Opaque + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _BaseMap: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _Cull: 2 + - _Metallic: 0 + - _Smoothness: 0.12 + - _Surface: 0 + m_Colors: + - _BaseColor: {r: 1, g: 1, b: 1, a: 1} + - _Color: {r: 1, g: 1, b: 1, a: 1} + m_BuildTextureStacks: [] + m_AllowLocking: 1 diff --git a/Assets/Resources/Map3D/Map3DLit.mat.meta b/Assets/Resources/Map3D/Map3DLit.mat.meta new file mode 100644 index 0000000..b59d8ad --- /dev/null +++ b/Assets/Resources/Map3D/Map3DLit.mat.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ec4d2749e73404eba2facea709d9f24 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 2100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/Map3D.cs b/Assets/Scripts/WorldMap/Map3D.cs index 236d2bb..a0d73f7 100644 --- a/Assets/Scripts/WorldMap/Map3D.cs +++ b/Assets/Scripts/WorldMap/Map3D.cs @@ -5,7 +5,7 @@ using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; // 3D НА ПРИМИТИВАХ: общие помощники для карты мира и 3D-поселения (переход на 2.5D, 04.11.2026). -// Материал берётся у встроенного примитива (шейдер URP по умолчанию точно есть в сборке), цвета кешируются. +// Материал -- свой, на URP Lit (Resources/Map3D/Map3DLit.mat), цвета кешируются. // Свои простые формы (конус, пирамида, двускатная крыша) -- для деревьев, шатров, крыш. // Подписи (TextMeshPro) поворачиваются к камере (FaceCamera). public static class Map3D @@ -20,9 +20,18 @@ public static class Map3D { if (baseMaterial == null) { - var probe = GameObject.CreatePrimitive(PrimitiveType.Cube); - baseMaterial = new Material(probe.GetComponent().sharedMaterial); - Object.Destroy(probe); + // Свой материал на URP Lit (Resources/Map3D/Map3DLit): материал встроенных примитивов в 2D-проекте -- + // спрайтовый, без света и без цвета. Нет ассета -- ищем шейдер по имени + var asset = Resources.Load("Map3D/Map3DLit"); + var shader = Shader.Find("Universal Render Pipeline/Lit"); + baseMaterial = asset != null ? new Material(asset) : shader != null ? new Material(shader) : null; + if (baseMaterial == null) + { + var probe = GameObject.CreatePrimitive(PrimitiveType.Cube); + baseMaterial = new Material(probe.GetComponent().sharedMaterial); + Object.Destroy(probe); + Debug.LogWarning("[3D] Нет материала Resources/Map3D/Map3DLit и шейдера URP Lit -- 3D будет без света"); + } materials.Clear(); } if (!materials.TryGetValue(color, out var m) || m == null) From fae42716a7fedc4486abd932f11b91075a1b850d Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:07:28 +0100 Subject: [PATCH 5/8] =?UTF-8?q?3D:=20=D0=B7=D0=B5=D0=BC=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=20=D0=BA=D1=80=D0=B0=D0=B5=D0=BC=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D1=8B=20=D0=B8=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20--=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BD=D0=B5=D0=B1?= =?UTF-8?q?=D0=B0=20(=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5=20?= =?UTF-8?q?=D1=82=D1=91=D0=BC=D0=BD=D0=B0=D1=8F,=20=D1=83=20=D0=B3=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B0=20--=20=D0=BB=D0=B5=D1=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/City3D/City3DController.cs | 13 +++++++++++++ Assets/Scripts/WorldMap/WorldMapController.cs | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index 00bde43..cc8c7a0 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -142,6 +142,19 @@ public class City3DController : MonoBehaviour go.AddComponent().sharedMaterial = Map3D.TexturedMat(groundTexture); go.AddComponent().sharedMesh = mesh; + // Лес и луга за краем участка: камера не видит пустого неба + Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.24f, 0.4f, 0.22f), new Vector3(n / 2f, -0.7f, n / 2f), + new Vector2(n + 200f, n + 200f), 0.1f); + var forest = new System.Random(7); + for (int i = 0; i < 140; i++) + { + // кольцо деревьев вокруг участка + float angle = (float)forest.NextDouble() * Mathf.PI * 2f, r = n * 0.72f + (float)forest.NextDouble() * 14f; + var p = new Vector3(n / 2f + Mathf.Cos(angle) * r, -0.6f, n / 2f + Mathf.Sin(angle) * r); + if (p.x > 0 && p.x < n && p.z > 0 && p.z < n) continue; // внутри участка -- свои деревья + Map3D.Tree(transform, "ForestTree", p, 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6)); + } + // Вода -- плоскость над руслом var water = GameObject.CreatePrimitive(PrimitiveType.Cube); water.name = "River"; diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 6218411..f904d75 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -342,6 +342,10 @@ public class WorldMapController : MonoBehaviour mesh.uv = new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) }; mesh.triangles = new[] { 0, 1, 2, 0, 2, 3 }; mesh.RecalculateNormals(); + // Земля за краем карты -- тёмная, как неисследованное: камера под наклоном не видит пустого неба + Map3D.Block(mapRoot, "OuterLand", PrimitiveType.Cube, new Color(0.12f, 0.16f, 0.13f), new Vector3(0, -0.08f, 0), + new Vector2(width + 160f, height + 160f), 0.06f); + var go = new GameObject("Ground"); go.transform.SetParent(mapRoot, false); go.AddComponent().sharedMesh = mesh; From cfbdfd9876e4b6cc302ae4814d62db0531a5bcf4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:13:02 +0100 Subject: [PATCH 6/8] =?UTF-8?q?=D0=95=D0=B4=D0=B0=20=D0=B2=D0=BE=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=D0=B8=20=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=BF=D0=BE=20=D1=82=D0=B8=D0=BF=D0=B0=D0=BC=20?= =?UTF-8?q?(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2001.11,=20=D0=BF.?= =?UTF-8?q?=204):=200,15=20/=200,20=20/=200,15=20/=200,15,=20=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=8B=D0=B5=20--=20=D0=B4=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IArmy.GetAllTroops; PopulationRules.FoodPerTroopTypePerHour (без таблицы -- как раньше); EconomyConfig.population.foodPerTroopTypePerHour. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/EconomyConfig.asset | 5 ++++ Assets/Scripts/App/EconomyConfig.cs | 5 +++- Assets/Scripts/App/GameModules.cs | 2 ++ Assets/Scripts/Modules/Army/ArmyService.cs | 9 ++++++ .../Modules/Core/Contracts/ModuleContracts.cs | 1 + .../Modules/Population/PopulationRules.cs | 4 +++ .../Modules/Population/PopulationService.cs | 13 +++++++- Assets/Scripts/Modules/Tests/ModuleTests.cs | 30 +++++++++++++++++++ Docs/Modules/Army.md | 1 + Docs/Modules/Population.md | 1 + Docs/Modules/README.md | 1 + 11 files changed, 70 insertions(+), 2 deletions(-) diff --git a/Assets/Resources/EconomyConfig.asset b/Assets/Resources/EconomyConfig.asset index 21f0383..8dfa34e 100644 --- a/Assets/Resources/EconomyConfig.asset +++ b/Assets/Resources/EconomyConfig.asset @@ -18,6 +18,11 @@ MonoBehaviour: startSoldiers: 30 foodPerCivilianPerHour: 0.1 foodPerSoldierPerHour: 0.2 + foodPerTroopTypePerHour: + - 0.15 + - 0.2 + - 0.15 + - 0.15 growthPerHour: 2.5 housingBase: 300 housingPerTownHallLevel: 50 diff --git a/Assets/Scripts/App/EconomyConfig.cs b/Assets/Scripts/App/EconomyConfig.cs index 9682259..f4fea3d 100644 --- a/Assets/Scripts/App/EconomyConfig.cs +++ b/Assets/Scripts/App/EconomyConfig.cs @@ -24,8 +24,11 @@ public class EconomyConfig : ScriptableObject [Tooltip("Еды в час на одного жителя")] public float foodPerCivilianPerHour = 0.1f; - [Tooltip("Еды в час на одного воина")] + [Tooltip("Еды в час на одного воина (и новобранца в Казармах), если норма по типам не задана")] public float foodPerSoldierPerHour = 0.2f; + [Tooltip("Еды в час на воина по типам: пехота, лучники, кавалерия, осадные. Решения 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15. " + + "Раненые едят так же, даже ожидая койку. Пусто -- все по foodPerSoldierPerHour")] + public float[] foodPerTroopTypePerHour = { 0.15f, 0.2f, 0.15f, 0.15f }; [Tooltip("Прирост людей в час, если есть еда (документ: 2–3)")] public float growthPerHour = 2.5f; diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index c588a24..cf63015 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -209,6 +209,8 @@ public static class GameModules StartSoldiers = p.startSoldiers, FoodPerCivilianPerHour = p.foodPerCivilianPerHour, FoodPerSoldierPerHour = p.foodPerSoldierPerHour, + FoodPerTroopTypePerHour = p.foodPerTroopTypePerHour != null && p.foodPerTroopTypePerHour.Length > 0 + ? System.Array.ConvertAll(p.foodPerTroopTypePerHour, f => (double)f) : null, GrowthPerHour = p.growthPerHour, HousingBase = p.housingBase, HousingPerTownHallLevel = p.housingPerTownHallLevel, diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index fcdbd33..b3ea5c0 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -238,6 +238,15 @@ public sealed class ArmyService : IArmy, ISaveParticipant } } + public int GetAllTroops(int type) + { + if (type < 0 || type >= Types) return 0; + int n = troops[type] + woundedWaiting[type]; + foreach (var m in marches) n += m.units[type]; + foreach (var b in healing) if (b.type == type) n += b.count; + return n; + } + public int GetWoundedWaiting(int type) { return type >= 0 && type < Types ? woundedWaiting[type] : 0; diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 6580fdf..bf4ddf5 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -150,6 +150,7 @@ public interface IArmy // Бой считает раненых, Армия хранит их и лечит, места -- от госпиталя (IHospital), цену лечения списывает Экономика. // Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11). int WoundedTotal { get; } // раненых всего: ждут места + лечатся + int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4) int GetWoundedWaiting(int type); // ждут места, по типам IReadOnlyList Healing { get; } // лечатся сейчас int HospitalBeds { get; } // мест в госпитале сейчас diff --git a/Assets/Scripts/Modules/Population/PopulationRules.cs b/Assets/Scripts/Modules/Population/PopulationRules.cs index 1201b7a..168a64c 100644 --- a/Assets/Scripts/Modules/Population/PopulationRules.cs +++ b/Assets/Scripts/Modules/Population/PopulationRules.cs @@ -12,6 +12,10 @@ public sealed class PopulationRules // Еда в час public double FoodPerCivilianPerHour = 0.1; public double FoodPerSoldierPerHour = 0.2; + // Еда воина по типам войск: пехота, лучники, кавалерия, осадные (решения по отчёту Саши, 01.11, п. 4: 0,15 / 0,20 / 0,15 / 0,15). + // Раненые едят полную норму своего типа, даже ожидая койку. null -- все воины едят FoodPerSoldierPerHour (как раньше). + // Новобранцы в Казармах -- по FoodPerSoldierPerHour (их норма не задана). + public double[] FoodPerTroopTypePerHour = null; // Прирост +2–3 человека в час, если есть еда public double GrowthPerHour = 2.5; diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index d9f2567..ae0e853 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -182,7 +182,18 @@ public sealed class PopulationService : IPopulation, ISaveParticipant double FoodPerHourExact(int soldiers) { // Обучающиеся в Казармах едят как воины - return civilians * rules.FoodPerCivilianPerHour + (soldiers + inTraining) * rules.FoodPerSoldierPerHour; + return civilians * rules.FoodPerCivilianPerHour + SoldiersFood(soldiers) + inTraining * rules.FoodPerSoldierPerHour; + } + + // Воины (в городе, в походах и раненые) едят по норме своего типа, если она задана (решения 01.11, п. 4) + double SoldiersFood(int soldiers) + { + var table = rules.FoodPerTroopTypePerHour; + if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return soldiers * rules.FoodPerSoldierPerHour; + double food = 0; + for (int t = 0; t < army.TypeCount; t++) + food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour); + return food; } public int GetWorkers(WorkJob job) diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index d8ab5fa..6ea7073 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1949,4 +1949,34 @@ public class ModuleTests Assert.AreEqual(20, army.GetWoundedWaiting(0) + 10, "ещё 20 легли сразу после улучшения: ждут 10"); Assert.AreEqual(40, army.Healing[0].Count + army.Healing[1].Count); } + + // Решения 01.11, п. 4: воины и раненые едят по норме своего типа, раненые -- даже ожидая койку + [Test] + public void Population_FoodByTroopType_IncludesWounded() + { + var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 0 }); // мест нет -- все раненые ждут + GameServices.Register(army); + GameServices.Register(new PopulationTests.FakeEconomy()); + GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); + var population = new PopulationService(new PopulationRules { FoodPerTroopTypePerHour = new[] { 0.15, 0.2, 0.15, 0.15 } }); + GameServices.Register(population); + double civilians = population.Civilians * 0.1; + + army.AddTroops(0, 20); // пехота 20 x 0,15 = 3 + army.AddTroops(1, 10); // лучники 10 x 0,20 = 2 + army.AddTroops(3, 4); // осадные 4 x 0,15 = 0,6 + Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001); + + army.TakeForMarch(1, 14); // в походе -- тоже едят + Assert.AreEqual(civilians + 5.6, population.FoodPerHour, 0.001, "поход не меняет еду"); + int[] sent = army.GetMarchUnits(1); + var wounded = new int[4]; + for (int t = 0; t < 4; t++) wounded[t] = sent[t] / 2; // половина ранена, остальные погибли + army.ReturnUnits(1, null, wounded, 0); + Assert.AreEqual(0, army.Healing.Count, "коек нет -- раненые ждут"); + double expected = 0; + for (int t = 0; t < 4; t++) expected += army.GetAllTroops(t) * new[] { 0.15, 0.2, 0.15, 0.15 }[t]; + Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму"); + Assert.Less(expected, 5.6, "погибшие больше не едят"); + } } diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index ce38eb2..a59ba2b 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -148,6 +148,7 @@ healNext=<номер следующей группы лечения> | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | `IArmy.GetAllTroops(type)` — все воины типа: в городе, в походах и раненые (для еды по типам, решения 01.11, п. 4). | | 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. | | 01.11.2026 | Claude | Здание «Госпиталь» в городе: уровни I–V, 20–60 мест, окно с ранеными и улучшением; места Армии — через `IHospital` (`HospitalBeds`). Без госпиталя мест нет (`temporaryBeds` = 0). Тест `Hospital_BedsFromBuilding_UpgradeFreesWaiting`. | | 01.11.2026 | Claude | Решения 31.10, п. 7: раненые и лечение у Армии — `WoundedTotal`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`, `SoldiersHealedEvent`; места от `IHospital` (пока нет здания — `HospitalConfig.temporaryBeds`), лишние ждут, цена через Экономику (не утверждена). Сохранение `wounded=`, `heal=`. Тесты: места и сроки, однократность, люди в Населении, сохранение и цена. | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 35029f6..571dd0b 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -112,6 +112,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude (Саша) | Решения по отчёту Саши от 01.11, п. 4: воины едят по норме типа — пехота 0,15, лучники 0,20, кавалерия 0,15, осадные 0,15 в час (`PopulationRules.FoodPerTroopTypePerHour`, `EconomyConfig.population.foodPerTroopTypePerHour`); раненые — полную норму своего типа, даже ожидая койку (`IArmy.GetAllTroops`). Новобранцы — по `foodPerSoldierPerHour`. Без таблицы — как раньше (тесты Населения не менялись). Час — тот же, что у остальной еды (переход еды на игровые часы — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. | | 31.10.2026 | Claude | `CompleteTraining`: новобранцы переходят в Армию одним действием с откатом, если Армия не приняла. Заказ обучения помечает, брали ли под него людей (`TrainingOrder.recruited`); старые заказы — по-старому. Интеграционные тесты с настоящей Армией: переход без потерь, отказ Армии, повтор, нет Армии, часть заказа. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 909d336..b9700f9 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 | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. | | 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. | | 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. | From 6c39c8b1026027fc6e1015a85927961fc04affde Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:16:43 +0100 Subject: [PATCH 7/8] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BF=D0=B0=D0=B2=D1=88?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20(=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=2001.11,?= =?UTF-8?q?=20=D0=BF.=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бой: отдельный статус среди выбывших (доля не утверждена -- 0, выключено), версия правил 8. Армия: пропавшие из похода -- в группу; окно 6 игровых часов после обратного пути, возвращение 2-4 группами, 70% здоровыми / 30% ранеными, остальные списываются; сохранение без повторного возвращения. Население: занимают места, но не едят. Окна: сколько пропало и сколько ещё может вернуться. Co-Authored-By: Claude Opus 5.5 --- Assets/Resources/BattleConfig.asset | 3 +- Assets/Resources/HospitalConfig.asset | 4 + Assets/Scripts/App/BattleConfig.cs | 4 + Assets/Scripts/App/GameModules.cs | 14 +- Assets/Scripts/App/HospitalConfig.cs | 21 ++ Assets/Scripts/Meta/Core/GameProgress.cs | 1 + Assets/Scripts/Meta/Panels/TroopsPanel.cs | 7 + Assets/Scripts/Modules/Army/ArmyService.cs | 225 +++++++++++++++++- Assets/Scripts/Modules/Battle/BattleRules.cs | 9 +- Assets/Scripts/Modules/Battle/BattleSystem.cs | 44 +++- .../Modules/Core/Contracts/ModuleContracts.cs | 22 +- .../Scripts/Modules/Core/Events/GameEvents.cs | 20 ++ .../Modules/Population/PopulationService.cs | 6 +- Assets/Scripts/Modules/Tests/ModuleTests.cs | 97 ++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 7 + .../World/Simulation/WorldSimulation.cs | 2 + Assets/Scripts/UI/MarchReturnPopup.cs | 3 +- Assets/Scripts/WorldMap/WorldMapController.cs | 7 +- Docs/Modules/Army.md | 12 + Docs/Modules/Battle.md | 1 + Docs/Modules/Core.md | 1 + Docs/Modules/Population.md | 1 + Docs/Modules/README.md | 1 + Docs/Modules/World.md | 1 + 24 files changed, 492 insertions(+), 21 deletions(-) diff --git a/Assets/Resources/BattleConfig.asset b/Assets/Resources/BattleConfig.asset index 9c5fc4c..22956ea 100644 --- a/Assets/Resources/BattleConfig.asset +++ b/Assets/Resources/BattleConfig.asset @@ -12,7 +12,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 7264ce138a844adc81fa69b77a27c8a6, type: 3} m_Name: BattleConfig m_EditorClassIdentifier: - version: 7 + version: 8 counterPercent: 25 wallsPercent: 20 siegeVsWallsPercent: 50 @@ -49,3 +49,4 @@ MonoBehaviour: - 450 - 300 - 200 + missingSharePermille: 0 diff --git a/Assets/Resources/HospitalConfig.asset b/Assets/Resources/HospitalConfig.asset index 99dffa2..3b0f0ec 100644 --- a/Assets/Resources/HospitalConfig.asset +++ b/Assets/Resources/HospitalConfig.asset @@ -20,3 +20,7 @@ MonoBehaviour: healGameHours: 04000000040000000600000008000000 healFoodPerSoldier: 00000000000000000000000000000000 healGoldPerSoldier: 01000000010000000200000003000000 + missingReturnPercent: 50 + missingWoundedPercent: 30 + missingWindowGameHours: 6 + missingMaxGroups: 4 diff --git a/Assets/Scripts/App/BattleConfig.cs b/Assets/Scripts/App/BattleConfig.cs index cfe2f27..624a011 100644 --- a/Assets/Scripts/App/BattleConfig.cs +++ b/Assets/Scripts/App/BattleConfig.cs @@ -42,6 +42,9 @@ public class BattleConfig : ScriptableObject [Tooltip("Доля погибших среди выбывших в каждой точке, десятые доли процента: 700 = 70%. Остальные выбывшие -- раненые")] public int[] deadSharePermille = { 700, 450, 300, 200 }; + [Tooltip("Пропавшие без вести (решения 01.11, п. 5): доля выбывших, десятые доли процента. НЕ УТВЕРЖДЕНА -- 0, механика выключена. Для проверки -- например 150 (15%)")] + public int missingSharePermille = 0; + [Tooltip("Голод: сколько силы остаётся, %. Утверждено 27.10.2026: -50% (остаётся 50). Проверяется на момент боя")] public int famineStrengthPercent = 50; [Tooltip("Усталость: сколько силы остаётся, %. Утверждено 27.10.2026: -20% (остаётся 80). Вместе с голодом -- 40%")] @@ -78,6 +81,7 @@ public class BattleConfig : ScriptableObject WeakLossMaxPermille = weakLossMaxPermille, DeadRatioPermille = deadRatioPermille, DeadSharePermille = deadSharePermille, + MissingSharePermille = Mathf.Clamp(missingSharePermille, 0, 1000), RandomPercent = Mathf.Max(0, randomPercent), FamineStrengthPercent = Mathf.Clamp(famineStrengthPercent, 0, 100), FatigueStrengthPercent = Mathf.Clamp(fatigueStrengthPercent, 0, 100) diff --git a/Assets/Scripts/App/GameModules.cs b/Assets/Scripts/App/GameModules.cs index cf63015..522a41b 100644 --- a/Assets/Scripts/App/GameModules.cs +++ b/Assets/Scripts/App/GameModules.cs @@ -42,7 +42,7 @@ public static class GameModules EventBus.Subscribe(_ => TestObservation()); // Армия -- чистый C#, единственное место хранения войск (секция "army") - var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules()); // госпиталь (решения 31.10, п. 7) + var army = new ArmyService(marchConfig.baseMarchSlots, HospitalConfig.Load().ToRules(), HospitalConfig.Load().ToMissingRules()); // госпиталь, пропавшие GameServices.Register(army); SaveRegistry.Register(army); EventBus.Subscribe(_ => { if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); }); @@ -50,6 +50,18 @@ public static class GameModules // Очередь обучения -- у Армии (решения 31.10, п. 8): статистика заданий по её событию, старая очередь -- переносится EventBus.Subscribe(e => { if (GameProgress.HasInstance) GameProgress.Instance.OnTroopsTrained(e); }); EventBus.Subscribe(_ => MigrateLegacyTraining(army)); + // Пропавшие без вести вернулись / списаны (решения 01.11, п. 5) + EventBus.Subscribe(e => + { + if (e.Healthy + e.Wounded > 0) + UIKit.Toast($"Вернулись пропавшие без вести: {e.Healthy + e.Wounded}" + (e.Wounded > 0 ? $" (ранены {e.Wounded} -- в госпиталь)" : "")); + if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); + }); + EventBus.Subscribe(e => + { + if (e.Count > 0) UIKit.Toast($"Не вернулись и списаны пропавшие без вести: {e.Count}"); + if (GameProgress.HasInstance) GameProgress.Instance.MarkChanged(); + }); // Раненые вылечены -- вернулись в город EventBus.Subscribe(e => { diff --git a/Assets/Scripts/App/HospitalConfig.cs b/Assets/Scripts/App/HospitalConfig.cs index 7341cce..a63ec72 100644 --- a/Assets/Scripts/App/HospitalConfig.cs +++ b/Assets/Scripts/App/HospitalConfig.cs @@ -33,6 +33,16 @@ public class HospitalConfig : ScriptableObject [Tooltip("Золота за воина: пехота, лучники, кавалерия, осадные")] public int[] healGoldPerSoldier = { 1, 1, 2, 3 }; + [Header("Пропавшие без вести (решения 01.11, п. 5). Долю пропавших в бою задаёт BattleConfig.missingSharePermille")] + [Tooltip("Сколько пропавших вернётся, %. Утверждён диапазон 10-90%, формула НЕ утверждена -- ТЕСТ")] + public int missingReturnPercent = 50; + [Tooltip("Из вернувшихся ранены, %. Утверждено: 30 (70% здоровыми)")] + public int missingWoundedPercent = 30; + [Tooltip("Окно возвращения после обратного пути, ИГРОВЫХ часов. Утверждено: до 6")] + public int missingWindowGameHours = 6; + [Tooltip("Возвращаются группами: от 2 до стольких. Утверждено: 2-4")] + public int missingMaxGroups = 4; + static HospitalConfig cached; public static HospitalConfig Load() @@ -47,6 +57,17 @@ public class HospitalConfig : ScriptableObject return cached; } + public MissingRules ToMissingRules() + { + return new MissingRules + { + ReturnPercent = Mathf.Clamp(missingReturnPercent, 0, 100), + WoundedPercent = Mathf.Clamp(missingWoundedPercent, 0, 100), + WindowGameHours = Mathf.Max(0, missingWindowGameHours), + MaxGroups = Mathf.Max(2, missingMaxGroups) + }; + } + public HospitalRules ToRules() { var hours = new int[TroopType.Count]; diff --git a/Assets/Scripts/Meta/Core/GameProgress.cs b/Assets/Scripts/Meta/Core/GameProgress.cs index 559723a..3e6ea03 100644 --- a/Assets/Scripts/Meta/Core/GameProgress.cs +++ b/Assets/Scripts/Meta/Core/GameProgress.cs @@ -721,6 +721,7 @@ public class GameProgress : MonoBehaviour long now = GameClock.UnixNowMs; army.ProcessTraining(now); army.ProcessHealing(now); + army.ProcessMissing(now); } // Заказ закончен (событие Армии, один раз на заказ): статистика заданий и всплывашка diff --git a/Assets/Scripts/Meta/Panels/TroopsPanel.cs b/Assets/Scripts/Meta/Panels/TroopsPanel.cs index 73f64d4..ca27d9f 100644 --- a/Assets/Scripts/Meta/Panels/TroopsPanel.cs +++ b/Assets/Scripts/Meta/Panels/TroopsPanel.cs @@ -45,6 +45,13 @@ public class TroopsPanel : BasePanel ProfilePanel.KV(army, "Раненые", hurt.WoundedTotal.ToString("N0") + " (лечатся " + healingNow + ", ждут места " + waiting + ")"); ProfilePanel.KV(army, "Госпиталь", "мест " + hurt.HospitalBeds + next); } + if (hurt.MissingTotal > 0) + { + long windowEnd = 0; + foreach (var m in hurt.Missing) windowEnd = System.Math.Max(windowEnd, m.EndMs); + ProfilePanel.KV(army, "Пропали без вести", hurt.MissingTotal.ToString("N0") + " (могут вернуться ещё " + + GameData.FormatTime(System.Math.Max(0, windowEnd - GameClock.UnixNowMs) / 1000.0) + ")"); + } ProfilePanel.KV(army, "Сила войск", GP.TroopsPower.ToString("N0")); ProfilePanel.KV(army, "Сила армии с героем", GP.ArmyPower.ToString("N0")); UIKit.Button(army, "Отправить армию на карте мира", UIKit.Theme.Green, () => OpenPanel("World"), -1, 80, 26); diff --git a/Assets/Scripts/Modules/Army/ArmyService.cs b/Assets/Scripts/Modules/Army/ArmyService.cs index b3ea5c0..35bb910 100644 --- a/Assets/Scripts/Modules/Army/ArmyService.cs +++ b/Assets/Scripts/Modules/Army/ArmyService.cs @@ -29,6 +29,18 @@ public sealed class HospitalRules } } +// Пропавшие без вести (решения 01.11, п. 5). Доля возвращающихся и формула -- НЕ УТВЕРЖДЕНЫ (ТЕСТ, таблица HospitalConfig) +public sealed class MissingRules +{ + public int ReturnPercent = 50; // сколько пропавших вернётся (утверждён диапазон 10-90%, формула открыта) + public int WoundedPercent = 30; // из вернувшихся -- раненые (утверждено: 70% здоровыми, 30% ранеными) + public int WindowGameHours = 6; // окно возвращения после обратного пути, ИГРОВЫХ часов + public long GameHourMs = 5 * 60 * 1000; + public int MaxGroups = 4; // возвращаются 2-4 группами (меньше, если пропавших мало) + + public static MissingRules Default => new MissingRules(); +} + public sealed class ArmyService : IArmy, ISaveParticipant { public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops @@ -62,6 +74,28 @@ public sealed class ArmyService : IArmy, ISaveParticipant private int nextHealId = 1; private readonly HospitalRules hospital; + // Пропавшие без вести одного боя: группы возвращения рассчитаны сразу, при постановке (детерминированно) + private class MissingEvent + { + public int id, marchId; + public long windowStartMs, endMs; + public int[] remaining = new int[Types]; // ещё не вернулись и не списаны + public List groups = new List(); + } + + private class MissingGroup + { + public int index; + public long timeMs; + public int[] healthy = new int[Types], wounded = new int[Types]; + } + + private readonly List missing = new List(); + private readonly List missingView = new List(); + private readonly HashSet missingMarches = new HashSet(); // по каким походам пропавшие уже отмечены + private int nextMissingId = 1; + private readonly MissingRules missingRules; + private readonly List training = new List(); private readonly List trainingView = new List(); private int nextOrderId = 1; @@ -76,10 +110,11 @@ public sealed class ArmyService : IArmy, ISaveParticipant // Слоты похода: база из таблицы MarchConfig (регламент 31.10: 2, ТЕСТ). Бонус «Военной логистики» -- позже. private readonly int baseMarchSlots; - public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null) + public ArmyService(int baseMarchSlots = 2, HospitalRules hospital = null, MissingRules missingRules = null) { this.baseMarchSlots = Math.Max(1, baseMarchSlots); this.hospital = hospital ?? HospitalRules.Default; + this.missingRules = missingRules ?? MissingRules.Default; } public HospitalRules Hospital => hospital; @@ -247,6 +282,138 @@ public sealed class ArmyService : IArmy, ISaveParticipant return n; } + // ---------------- Пропавшие без вести ---------------- + + public int MissingTotal + { + get { int n = 0; foreach (var e in missing) foreach (var r in e.remaining) n += r; return n; } + } + + public IReadOnlyList Missing + { + get + { + missingView.Clear(); + foreach (var e in missing) + { + int n = 0; + foreach (var r in e.remaining) n += r; + missingView.Add(new MissingInfo { Id = e.id, Count = n, WindowStartMs = e.windowStartMs, EndMs = e.endMs }); + } + return missingView; + } + } + + public bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs) + { + if (missingUnits == null || missingMarches.Contains(marchId)) return false; + MarchUnits entry = marches.Find(m => m.marchId == marchId); + if (entry == null) return false; + + var e = new MissingEvent { id = nextMissingId++, marchId = marchId }; + int total = 0; + for (int i = 0; i < Types && i < missingUnits.Length; i++) + { + int n = Math.Max(0, Math.Min(missingUnits[i], entry.units[i])); + entry.units[i] -= n; // из похода -- в пропавшие: один солдат в одном месте + e.remaining[i] = n; + total += n; + } + if (total == 0) { nextMissingId--; return false; } + + e.windowStartMs = battleMs + Math.Max(0, travelMs); + e.endMs = e.windowStartMs + Math.Max(0, missingRules.WindowGameHours) * missingRules.GameHourMs; + PlanGroups(e, battleMs); + missing.Add(e); + missingMarches.Add(marchId); + Changed(); + return true; + } + + // Кто вернётся и когда: доля возвращающихся по типам (дробная часть -- бросок), 2-4 группы в окне, + // в каждой -- 70% здоровых, 30% раненых (дробная часть -- бросок). Тип войск сохраняется. + void PlanGroups(MissingEvent e, long seedTime) + { + ulong seed = (ulong)e.marchId * 0x9E3779B97F4A7C15UL ^ (ulong)seedTime; + int roll = 0; + bool Chance(long num, long den) => den > 0 && (long)(Next(seed, roll++) % (ulong)den) < num; + + var back = new int[Types]; + int returning = 0; + for (int i = 0; i < Types; i++) + { + long num = (long)e.remaining[i] * Math.Max(0, Math.Min(100, missingRules.ReturnPercent)); + back[i] = (int)(num / 100) + (Chance(num % 100, 100) ? 1 : 0); + returning += back[i]; + } + if (returning == 0) return; + + int groups = Math.Max(1, Math.Min(Math.Max(2, missingRules.MaxGroups), returning)); // мало вернувшихся -- групп меньше + if (returning >= 2) groups = Math.Max(2, Math.Min(groups, 2 + (int)(Next(seed, roll++) % (ulong)(Math.Max(2, missingRules.MaxGroups) - 1)))); + long window = Math.Max(1, e.endMs - e.windowStartMs); + for (int g = 0; g < groups; g++) + e.groups.Add(new MissingGroup { index = g + 1, timeMs = e.windowStartMs + window * (g + 1) / (groups + 1) }); + + // Раздаём вернувшихся по группам по очереди, внутри группы -- здоровые и раненые + for (int i = 0; i < Types; i++) + for (int k = 0; k < back[i]; k++) + { + var grp = e.groups[(k + i) % groups]; + if (Chance(Math.Max(0, Math.Min(100, missingRules.WoundedPercent)), 100)) grp.wounded[i]++; + else grp.healthy[i]++; + } + } + + static ulong Next(ulong seed, int n) + { + ulong x = seed + 0x9E3779B97F4A7C15UL * (ulong)(n + 1); + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL; + return x ^ (x >> 31); + } + + public int ProcessMissing(long nowMs) + { + int done = 0; + bool changed = false; + for (int k = missing.Count - 1; k >= 0; k--) + { + var e = missing[k]; + e.groups.Sort((a, b) => a.timeMs.CompareTo(b.timeMs)); + while (e.groups.Count > 0 && e.groups[0].timeMs <= nowMs && e.groups[0].timeMs <= e.endMs) + { + var g = e.groups[0]; + e.groups.RemoveAt(0); // группа возвращается один раз + int healthy = 0, wounded = 0; + bool anyWounded = false; + for (int i = 0; i < Types; i++) + { + int h = Math.Min(g.healthy[i], e.remaining[i]); + int w = Math.Min(g.wounded[i], e.remaining[i] - h); + e.remaining[i] -= h + w; + troops[i] += h; // исходной армии может уже не быть -- в резерв поселения + woundedWaiting[i] += w; // раненые -- в госпиталь по обычным правилам + healthy += h; wounded += w; + if (w > 0) anyWounded = true; + } + if (anyWounded) { ProcessHealing(g.timeMs); FillBeds(g.timeMs); } + done++; + changed = true; + EventBus.Publish(new MissingReturnedEvent { MissingId = e.id, Group = g.index, Healthy = healthy, Wounded = wounded, TimeMs = g.timeMs }); + } + if (nowMs >= e.endMs) + { + int left = 0; + foreach (var r in e.remaining) left += r; + missing.RemoveAt(k); // окно кончилось: кто не вернулся -- списан, места освобождены + changed = true; + EventBus.Publish(new MissingWrittenOffEvent { MissingId = e.id, Count = left, TimeMs = e.endMs }); + } + } + if (changed) Changed(); + return done; + } + public int GetWoundedWaiting(int type) { return type >= 0 && type < Types ? woundedWaiting[type] : 0; @@ -425,7 +592,8 @@ public sealed class ArmyService : IArmy, ISaveParticipant void Changed() { - EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal }); + EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches, Garrison = GarrisonTotal, Wounded = WoundedTotal, + Missing = MissingTotal }); } // ---------------- Перенос из старого progress.json ---------------- @@ -462,6 +630,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant // wounded=<пехота>,<лучники>,<кавалерия>,<осадные> (ждут места в госпитале) // heal=|<тип>|<сколько>|<начало, мс>|<конец, мс> // healNext=<номер следующей группы лечения> + // missing=|<поход>|<окно с, мс>|<конец, мс>|<осталось по типам через ,> + // mgroup=|<номер группы>|<время, мс>|<здоровые по типам>|<раненые по типам> (ещё не вернувшиеся группы) + // missingMarch=<поход> (по нему пропавшие уже отмечены) missingNext=<номер> public string SaveKey => "army"; @@ -486,6 +657,20 @@ public sealed class ArmyService : IArmy, ISaveParticipant N(b.id), N(b.type), N(b.count), b.startMs.ToString(CultureInfo.InvariantCulture), b.endMs.ToString(CultureInfo.InvariantCulture) })).Append('\n'); sb.Append("healNext=").Append(N(nextHealId)).Append('\n'); + foreach (var e in missing) + { + sb.Append("missing=").Append(string.Join("|", new[] + { + N(e.id), N(e.marchId), e.windowStartMs.ToString(CultureInfo.InvariantCulture), e.endMs.ToString(CultureInfo.InvariantCulture), Join(e.remaining) + })).Append('\n'); + foreach (var g in e.groups) + sb.Append("mgroup=").Append(string.Join("|", new[] + { + N(e.id), N(g.index), g.timeMs.ToString(CultureInfo.InvariantCulture), Join(g.healthy), Join(g.wounded) + })).Append('\n'); + } + foreach (var id in missingMarches) sb.Append("missingMarch=").Append(N(id)).Append('\n'); + sb.Append("missingNext=").Append(N(nextMissingId)).Append('\n'); return sb.ToString(); } @@ -499,6 +684,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant Array.Clear(woundedWaiting, 0, Types); healing.Clear(); nextHealId = 1; + missing.Clear(); + missingMarches.Clear(); + nextMissingId = 1; RestoredFromSave = true; if (!string.IsNullOrEmpty(data)) @@ -556,6 +744,36 @@ public sealed class ArmyService : IArmy, ISaveParticipant nextHealId = Math.Max(nextHealId, hid + 1); } } + else if (line.StartsWith("missing=")) + { + string[] p = line.Substring("missing=".Length).Split('|'); + if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mid) + && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int mmarch) + && long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mstart) + && long.TryParse(p[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long mend) + && !missing.Exists(x => x.id == mid)) + { + missing.Add(new MissingEvent { id = mid, marchId = mmarch, windowStartMs = mstart, endMs = mend, remaining = Parse(p[4]) }); + nextMissingId = Math.Max(nextMissingId, mid + 1); + } + } + else if (line.StartsWith("mgroup=")) + { + string[] p = line.Substring("mgroup=".Length).Split('|'); + MissingEvent owner = null; + if (p.Length == 5 && int.TryParse(p[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gid)) + owner = missing.Find(x => x.id == gid); // строки missing= идут раньше своих групп + if (owner != null && int.TryParse(p[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int gidx) + && long.TryParse(p[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out long gtime) + && !owner.groups.Exists(x => x.index == gidx)) + owner.groups.Add(new MissingGroup { index = gidx, timeMs = gtime, healthy = Parse(p[3]), wounded = Parse(p[4]) }); + } + else if (line.StartsWith("missingMarch=") + && int.TryParse(line.Substring("missingMarch=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mm)) + missingMarches.Add(mm); + else if (line.StartsWith("missingNext=") + && int.TryParse(line.Substring("missingNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int mnext)) + nextMissingId = Math.Max(nextMissingId, mnext); else if (line.StartsWith("healNext=") && int.TryParse(line.Substring("healNext=".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out int hnext)) nextHealId = Math.Max(nextHealId, hnext); @@ -579,6 +797,9 @@ public sealed class ArmyService : IArmy, ISaveParticipant Array.Clear(woundedWaiting, 0, Types); healing.Clear(); nextHealId = 1; + missing.Clear(); + missingMarches.Clear(); + nextMissingId = 1; Changed(); } diff --git a/Assets/Scripts/Modules/Battle/BattleRules.cs b/Assets/Scripts/Modules/Battle/BattleRules.cs index 1a9b697..22c6b12 100644 --- a/Assets/Scripts/Modules/Battle/BattleRules.cs +++ b/Assets/Scripts/Modules/Battle/BattleRules.cs @@ -6,9 +6,10 @@ // итог боя запоминает версию, по которой посчитан. public sealed class BattleRules { - public int Version = 7; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14); + public int Version = 8; // 2 -- голод и усталость; 3 -- сила без округления, потери шансом (№12, №13); 4 -- кривая потерь (№14); // 5 -- разгром дальше 5:1 (решения по 8 вопросам Саши, 31.10, п. 5); 6 -- раненые (п. 7); - // 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1) + // 7 -- сильная сторона 5:1 -> 10:1 с 11% до 2% (решения по отчёту Саши, 01.11, п. 1); + // 8 -- пропавшие без вести (п. 5) // Сила одного воина каждого типа. В игре -- из GameData.Troops (там же цена и время обучения). public int[] UnitPower = { 4, 5, 9, 16 }; @@ -37,6 +38,10 @@ public sealed class BattleRules public int[] DeadRatioPermille = { 200, 500, 1000, 3000 }; // соотношение сил стороны: 1:5, 1:2, 1:1, 3:1 public int[] DeadSharePermille = { 700, 450, 300, 200 }; // доля погибших среди выбывших: 70%, 45%, 30%, 20% + // Пропавшие без вести (решения 01.11, п. 5): доля выбывших, получающих этот статус, в десятых долях процента. + // НЕ УТВЕРЖДЕНА командой -- по умолчанию 0 (механика выключена). Остальные выбывшие -- погибшие и раненые, как выше. + public int MissingSharePermille = 0; + // Состояния (утверждено 27.10.2026): сколько силы остаётся. Применяются последовательно: 50% x 80% = 40% public int FamineStrengthPercent = 50; // голод: -50% public int FatigueStrengthPercent = 80; // усталость: -20% diff --git a/Assets/Scripts/Modules/Battle/BattleSystem.cs b/Assets/Scripts/Modules/Battle/BattleSystem.cs index 1791db0..67e49a1 100644 --- a/Assets/Scripts/Modules/Battle/BattleSystem.cs +++ b/Assets/Scripts/Modules/Battle/BattleSystem.cs @@ -181,15 +181,20 @@ public sealed class BattleSystem : IBattleSystem int[] attOut = Losses(att, weaker, stronger, isWeak: !victory, request.Seed, side: 1, rounding); int[] defOut0 = Losses(def, weaker, stronger, isWeak: victory, request.Seed, side: 2, rounding); - // Выбывшие -- погибшие и раненые (решения 31.10, п. 7): доля погибших -- от соотношения сил своей стороны - int[] attDead = Dead(attOut, attPower, defPower, request.Seed, side: 1, rounding); - int[] defDead = Dead(defOut0, defPower, attPower, request.Seed, side: 2, rounding); + // Выбывшие: сначала пропавшие без вести (решения 01.11, п. 5), остальные -- погибшие и раненые (решения 31.10, п. 7): + // доля погибших -- от соотношения сил своей стороны. Солдат -- ровно в одном статусе + int[] attMissing = Missing(attOut, request.Seed, side: 1, rounding); + int[] defMissing = Missing(defOut0, request.Seed, side: 2, rounding); + int[] attRest = new int[TroopType.Count], defRest = new int[TroopType.Count]; + for (int i = 0; i < TroopType.Count; i++) { attRest[i] = attOut[i] - attMissing[i]; defRest[i] = defOut0[i] - defMissing[i]; } + int[] attDead = Dead(attRest, attPower, defPower, request.Seed, side: 1, rounding); + int[] defDead = Dead(defRest, defPower, attPower, request.Seed, side: 2, rounding); int[] attWounded = new int[TroopType.Count]; int[] defWounded = new int[TroopType.Count]; for (int i = 0; i < TroopType.Count; i++) { - attWounded[i] = attOut[i] - attDead[i]; - defWounded[i] = defOut0[i] - defDead[i]; + attWounded[i] = attRest[i] - attDead[i]; + defWounded[i] = defRest[i] - defDead[i]; } var attAlive = new int[TroopType.Count]; @@ -197,9 +202,9 @@ public sealed class BattleSystem : IBattleSystem var defOut = new int[TroopType.Count]; for (int i = 0; i < TroopType.Count; i++) { - attAlive[i] = att[i] - attDead[i] - attWounded[i]; - defAlive[i] = def[i] - defDead[i] - defWounded[i]; - defOut[i] = defDead[i] + defWounded[i]; + attAlive[i] = att[i] - attOut[i]; + defAlive[i] = def[i] - defOut0[i]; + defOut[i] = defOut0[i]; } return new BattleOutcome @@ -207,7 +212,7 @@ public sealed class BattleSystem : IBattleSystem Victory = victory, BattleId = request.Seed, RulesVersion = rules.Version, - AttackerLosses = Sum(attDead) + Sum(attWounded), + AttackerLosses = Sum(attOut), DefenderLosses = Sum(defOut), Survivors = Sum(attAlive), AttackerPower = attPower.Floor, // округление -- только для показа @@ -224,6 +229,8 @@ public sealed class BattleSystem : IBattleSystem DefenderDeadUnits = defDead, AttackerWoundedUnits = attWounded, DefenderWoundedUnits = defWounded, + AttackerMissingUnits = attMissing, + DefenderMissingUnits = defMissing, DefenderLossUnits = defOut }; } @@ -340,6 +347,25 @@ public sealed class BattleSystem : IBattleSystem return lost; } + // Пропавшие без вести среди выбывших каждого типа: доля из правил (не утверждена; 0 -- никого), дробная часть -- бросок + // из своего потока. Прогноз: Floor -- меньше пропавших, Ceiling -- больше + int[] Missing(int[] outUnits, long seed, int side, Rounding rounding) + { + var missing = new int[TroopType.Count]; + int share = Math.Max(0, Math.Min(1000, rules.MissingSharePermille)); + if (share == 0) return missing; + for (int i = 0; i < TroopType.Count; i++) + { + if (outUnits[i] <= 0) continue; + long num = (long)outUnits[i] * share; + long whole = num / 1000, rest = num % 1000; + bool extra = rest != 0 && (rounding == Rounding.Ceiling + || rounding == Rounding.Roll && (LossRoll(seed, side + 24, i) * 1000UL >> 32) < (ulong)rest); + missing[i] = (int)Math.Min(outUnits[i], whole + (extra ? 1 : 0)); + } + return missing; + } + // Погибшие среди выбывших каждого типа: доля по соотношению сил стороны (own / enemy), дробная часть -- бросок // из своего потока (не тот, что у потерь и удачи). Прогноз: Floor -- меньше погибших, Ceiling -- больше. int[] Dead(int[] outUnits, Fraction own, Fraction enemy, long seed, int side, Rounding rounding) diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index bf4ddf5..e16b134 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -86,6 +86,15 @@ public interface IPopulation } // ---------------- Армия: войска ---------------- +// Пропавшие без вести одного боя (решения 01.11, п. 5). Время -- мс часов игры +public struct MissingInfo +{ + public int Id; + public int Count; // ещё не вернулись и не списаны + public long WindowStartMs; // с какого момента могут начать возвращаться (бой + обратный путь) + public long EndMs; // конец окна: кто не вернулся -- списан +} + // Раненые на лечении в госпитале: одна группа одного типа, лечится вместе. Время -- мс часов игры public struct HealingInfo { @@ -151,6 +160,16 @@ public interface IArmy // Раненые -- люди: Население считает их вместе с воинами. Лишние раненые ждут места (ТЕСТ, решение Саши 01.11). int WoundedTotal { get; } // раненых всего: ждут места + лечатся int GetAllTroops(int type); // все воины типа: в городе, в походах и раненые (для еды -- решения 01.11, п. 4) + + // ---- Пропавшие без вести (решения 01.11, п. 5) ---- + // Не воюют и не едят, но занимают места в населении до возвращения или списания. + int MissingTotal { get; } + IReadOnlyList Missing { get; } + // Бой посчитал пропавших: забрать их из похода в группу пропавших. battleMs -- конец боя, travelMs -- обратный путь. + // Только известный поход и только один раз на поход; больше, чем ушло, не забрать. + bool MarkMissing(int marchId, int[] missingUnits, long battleMs, long travelMs); + // Вернуть группы, время которых наступило (и офлайн), и списать тех, чьё окно кончилось. Сколько групп вернулось. + int ProcessMissing(long nowMs); int GetWoundedWaiting(int type); // ждут места, по типам IReadOnlyList Healing { get; } // лечатся сейчас int HospitalBeds { get; } // мест в госпитале сейчас @@ -299,7 +318,8 @@ public struct BattleOutcome public int[] AttackerUnitsBefore, DefenderUnitsBefore; public int[] AttackerSurvivorUnits, DefenderSurvivorUnits; public int[] AttackerDeadUnits, DefenderDeadUnits; - public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7): выбывшие минус погибшие + public int[] AttackerWoundedUnits, DefenderWoundedUnits; // раненые (регламент 31.10, решения 31.10, п. 7) + public int[] AttackerMissingUnits, DefenderMissingUnits; // пропавшие без вести (решения 01.11, п. 5): выбывшие = погибшие + раненые + пропавшие public int[] DefenderLossUnits; // погибшие + раненые защитника (для старого кода) } diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 7310dc1..02ae156 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -63,6 +63,25 @@ public struct TroopsChangedEvent public int InMarches; public int Garrison; // из AtHome -- в гарнизоне public int Wounded; // раненые: в госпитале или ждут места (не в AtHome) + public int Missing; // пропавшие без вести: занимают места в населении, но не едят и не воюют +} + +// Пропавшие без вести вернулись (часть группы боя): здоровые -- в город, раненые -- в госпиталь. Один раз на группу +public struct MissingReturnedEvent +{ + public int MissingId; + public int Group; + public int Healthy; + public int Wounded; + public long TimeMs; +} + +// Окно возвращения кончилось: кто не вернулся -- списан, места в населении освобождены +public struct MissingWrittenOffEvent +{ + public int MissingId; + public int Count; + public long TimeMs; } // Раненые вылечены и вернулись в город. Один раз на группу лечения (BatchId), TimeMs -- когда закончили @@ -117,6 +136,7 @@ public struct MarchReturnedEvent public bool IsGather; // поход на ресурсную точку (сбор, а не бой) public bool IsScout; // поход «Разведка»: дошли до цели и вернулись, боя и добычи нет public int Wounded; // раненых привезли домой -- они в госпитале (или ждут места) + public int Missing; // пропали без вести в бою -- могут вернуться сами public string TargetName; // куда ходили ("Замок Змея Горыныча", имя соседа) public string LootText; // добыча текстом: "+20 золота, +40 еды" или "нет" } diff --git a/Assets/Scripts/Modules/Population/PopulationService.cs b/Assets/Scripts/Modules/Population/PopulationService.cs index ae0e853..5d7ba3e 100644 --- a/Assets/Scripts/Modules/Population/PopulationService.cs +++ b/Assets/Scripts/Modules/Population/PopulationService.cs @@ -79,7 +79,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant get { if (!GameServices.TryGet(out IArmy army)) return 0; - return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal; // раненые -- тоже люди (решения 31.10, п. 7) + // раненые -- тоже люди (решения 31.10, п. 7); пропавшие без вести занимают места до возвращения (решения 01.11, п. 5) + return army.SoldiersAtHome + army.TroopsInMarches + army.WoundedTotal + army.MissingTotal; } } @@ -189,7 +190,8 @@ public sealed class PopulationService : IPopulation, ISaveParticipant double SoldiersFood(int soldiers) { var table = rules.FoodPerTroopTypePerHour; - if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return soldiers * rules.FoodPerSoldierPerHour; + int missingNow = GameServices.TryGet(out IArmy a) ? a.MissingTotal : 0; // пропавшие не едят + if (table == null || table.Length == 0 || !GameServices.TryGet(out IArmy army)) return Math.Max(0, soldiers - missingNow) * rules.FoodPerSoldierPerHour; double food = 0; for (int t = 0; t < army.TypeCount; t++) food += army.GetAllTroops(t) * (t < table.Length ? table[t] : rules.FoodPerSoldierPerHour); diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 6ea7073..bd501aa 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1979,4 +1979,101 @@ public class ModuleTests Assert.AreEqual(civilians + expected, population.FoodPerHour, 0.001, "ждущие койку раненые едят полную норму"); Assert.Less(expected, 5.6, "погибшие больше не едят"); } + + // ---------------- Пропавшие без вести (решения 01.11, п. 5) ---------------- + + [Test] + public void Battle_Missing_IsSeparateStatus_OffByDefault() + { + BattleOutcome Fight(int share) + { + var rules = BattleRules.NoRandom; + rules.MissingSharePermille = share; + return new BattleSystem(rules).Resolve(new BattleRequest { Attackers = 1000, Defenders = 1000, Seed = 5 }); + } + BattleOutcome off = Fight(0); + Assert.AreEqual(0, off.AttackerMissingUnits[0], "доля не утверждена -- по умолчанию пропавших нет"); + + BattleOutcome on = Fight(200); // 20% выбывших + Assert.AreEqual(90, on.AttackerMissingUnits[0], "20% от 450 выбывших"); + Assert.AreEqual(450, on.AttackerDeadUnits[0] + on.AttackerWoundedUnits[0] + on.AttackerMissingUnits[0], "каждый -- ровно в одном статусе"); + Assert.AreEqual(off.Survivors, on.Survivors, "выжившие не меняются"); + Assert.AreEqual(off.AttackerLosses, on.AttackerLosses); + } + + static ArmyService MissingArmy(MissingRules rules = null) + { + var army = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }, rules ?? new MissingRules()); + GameServices.Register(army); + army.AddTroops(0, 60); + army.AddTroops(1, 40); + Assert.IsTrue(army.TakeForMarch(1, 100)); + return army; + } + + [Test] + public void Army_Missing_ReturnInGroups_ThenWrittenOff() + { + const long H = 5 * 60_000; // игровой час + var army = MissingArmy(new MissingRules { ReturnPercent = 50, WoundedPercent = 30 }); + var back = new List(); + var off = new List(); + EventBus.Subscribe(e => back.Add(e)); + EventBus.Subscribe(e => off.Add(e)); + + long battle = 1000, travel = 8 * H; + Assert.IsTrue(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel)); + Assert.IsFalse(army.MarkMissing(1, new[] { 30, 10, 0, 0 }, battle, travel), "один раз на поход"); + Assert.AreEqual(40, army.MissingTotal); + Assert.AreEqual(60, army.TroopsInMarches, "пропавшие ушли из похода"); + Assert.AreEqual(battle + travel, army.Missing[0].WindowStartMs, "окно -- после обратного пути"); + Assert.AreEqual(battle + travel + 6 * H, army.Missing[0].EndMs, "окно -- 6 игровых часов"); + + Assert.AreEqual(0, army.ProcessMissing(battle + travel), "до окна никто не вернулся"); + army.ProcessMissing(battle + travel + 6 * H - 1); + Assert.IsTrue(back.Count >= 2 && back.Count <= 4, "2-4 группы: " + back.Count); + int healthy = 0, wounded = 0; + foreach (var e in back) { healthy += e.Healthy; wounded += e.Wounded; } + Assert.AreEqual(20, healthy + wounded, "вернулась половина"); + Assert.IsTrue(wounded >= 2 && wounded <= 12, "около 30% раненых: " + wounded); + Assert.AreEqual(wounded, army.WoundedTotal, "раненые -- в госпиталь"); + + army.ProcessMissing(battle + travel + 6 * H); + Assert.AreEqual(1, off.Count); + Assert.AreEqual(20, off[0].Count, "остальные списаны"); + Assert.AreEqual(0, army.MissingTotal); + int groupsBefore = back.Count; + army.ProcessMissing(battle + travel + 100 * H); + Assert.AreEqual(groupsBefore, back.Count, "повторно никто не возвращается"); + } + + [Test] + public void Army_Missing_SaveRestore_NoDoubleReturn_AndPopulationReserve() + { + const long H = 5 * 60_000; + var army = MissingArmy(); + GameServices.Register(new PopulationTests.FakeEconomy()); + GameServices.Register(new PopulationTests.FakeSettlement { TownHallLevel = 1 }); + var population = new PopulationService(new PopulationRules()); + GameServices.Register(population); + int total = population.Total; + float food = population.FoodPerHour; + + army.MarkMissing(1, new[] { 20, 0, 0, 0 }, 0, 0); + Assert.AreEqual(total, population.Total, "пропавшие занимают места в населении"); + Assert.AreEqual(food - 20 * 0.2f, population.FoodPerHour, 0.001f, "но не едят"); + + army.ProcessMissing(3 * H); // часть групп вернулась + string saved = army.Capture(); + int home = army.GetTroops(0), hurt = army.WoundedTotal, missing = army.MissingTotal; + + var copy = new ArmyService(2, new HospitalRules { TemporaryBeds = 100 }); + copy.Restore(saved); + Assert.AreEqual(saved, copy.Capture(), "пропавшие и их группы сохраняются"); + Assert.AreEqual(missing, copy.MissingTotal); + copy.ProcessMissing(3 * H); + Assert.AreEqual(home, copy.GetTroops(0), "после загрузки уже вернувшиеся не возвращаются второй раз"); + Assert.AreEqual(hurt, copy.WoundedTotal); + Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки"); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index de74e67..8eb22bc 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -239,6 +239,12 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant void HandleBattleResolved(MarchData march) { + // Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами + if (Sum(march.missingUnits) > 0 && GameServices.TryGet(out IArmy army)) + { + army.MarkMissing(march.id, march.missingUnits, march.arriveMs, Math.Max(0, march.returnMs - march.arriveMs)); + army.ProcessMissing(NowMs); + } MapNode target = World.GetNode(march.targetNodeId); if (march.targetEmpty) Debug.Log($"[ПОХОД] {target.displayName} уже разбит -- войско возвращается без боя"); else Debug.Log($"[ПОХОД] Бой у {target.displayName}: {(march.victory ? "ПОБЕДА" : "поражение")}. " + @@ -310,6 +316,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant IsGather = march.gather, IsScout = march.scout, Wounded = Sum(march.woundedUnits), + Missing = Sum(march.missingUnits), TargetName = World.TargetName(march), LootText = FormatLoot(march.loot) }); diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 14bcea3..d61ef03 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -37,6 +37,7 @@ namespace Bogatyri.World public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой public int[] survivorUnits; // выжившие по типам (известно после боя) public int[] woundedUnits; // раненые по типам: идут домой с войском и ложатся в госпиталь (решения 31.10, п. 7) + public int[] missingUnits; // пропавшие без вести по типам: возвращаются сами, группами (решения 01.11, п. 5) public long battleSeed; // номер боя: из него случайность сторон. Задаётся при выступлении и сохраняется public long attackerPower; // итог боя: силы сторон x100 и выпавшая случайность (десятые доли процента) @@ -454,6 +455,7 @@ namespace Bogatyri.World m.survivors = outcome.Survivors; m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null; m.woundedUnits = HasUnits(m) ? outcome.AttackerWoundedUnits : null; // старый поход без состава -- раненые как погибшие + m.missingUnits = HasUnits(m) ? outcome.AttackerMissingUnits : null; if (m.survivors > 0) // обратно идут выжившие -- со скоростью своего состава m.returnMs = m.arriveMs + TravelTimeMs(target, from, m.survivorUnits ?? (HasUnits(m) ? m.units : null)); m.defenders = target.garrison; diff --git a/Assets/Scripts/UI/MarchReturnPopup.cs b/Assets/Scripts/UI/MarchReturnPopup.cs index e7f2790..b5d43a3 100644 --- a/Assets/Scripts/UI/MarchReturnPopup.cs +++ b/Assets/Scripts/UI/MarchReturnPopup.cs @@ -39,11 +39,12 @@ public static class MarchReturnPopup ? (e.TargetEmpty ? "точки уже не было или её заняли, сбора не было" : "ресурсы собраны") : e.TargetEmpty ? "лагерь уже был разбит, боя не было" : e.Victory ? "победа" : "поражение"; - int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded); + int dead = Mathf.Max(0, e.Sent - e.Survivors - e.Wounded - e.Missing); string text = $"{target}: {result}\n" + $"Вернулись {e.Survivors} из {e.Sent} воинов" + (dead > 0 ? $" (погибли {dead})" : "") + "\n" + (e.Wounded > 0 ? $"Раненых {e.Wounded} -- в госпитале (окно «Войска»)\n" : "") + + (e.Missing > 0 ? $"Пропали без вести {e.Missing} -- часть может вернуться сама\n" : "") + $"Добыча: {e.LootText}"; UIKit.Message("ВОЙСКО ВЕРНУЛОСЬ", text, "ОК", ShowNext); diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index f904d75..3b88b8d 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -799,9 +799,12 @@ public class WorldMapController : MonoBehaviour // «(из них ранены 12 -- придут в госпиталь)»: раненые идут домой с войском static string WoundedText(MarchData march) { - int w = 0; + int w = 0, missing = 0; if (march.woundedUnits != null) foreach (var u in march.woundedUnits) w += u; - return w > 0 ? $" (ранены {w} -- придут в госпиталь)" : ""; + if (march.missingUnits != null) foreach (var u in march.missingUnits) missing += u; + string text = w > 0 ? $"ранены {w} -- придут в госпиталь" : ""; + if (missing > 0) text += (text.Length > 0 ? ", " : "") + $"пропали {missing}"; + return text.Length > 0 ? $" ({text})" : ""; } void HandleGatherStarted(MarchData march) diff --git a/Docs/Modules/Army.md b/Docs/Modules/Army.md index a59ba2b..fd706ac 100644 --- a/Docs/Modules/Army.md +++ b/Docs/Modules/Army.md @@ -58,6 +58,17 @@ - Госпиталь улучшили — ждущие ложатся сразу (в течение секунды). - **Дальше:** герои — отдельная очередь лечения (регламент), когда появятся герои. +## Пропавшие без вести + +Решения по отчёту Саши от 01.11, п. 5. +- Бой отделяет часть выбывших в статус «пропал без вести» (`BattleOutcome.AttackerMissingUnits`): солдат ровно в одном статусе — погиб, ранен или пропал. Доля — `BattleConfig.missingSharePermille`, **не утверждена: 0, механика выключена**. Для проверки — например 150 (15%). +- При бое Мир передаёт пропавших Армии (`MarkMissing`): они уходят из похода в отдельную группу. Один раз на поход. +- Пропавшие не воюют и не едят, но занимают места в населении до возвращения или списания. +- Окно возвращения открывается после обратного пути от места боя (бой + время пути домой) и длится до 6 игровых часов. Возвращаются 2–4 группами (меньше, если пропавших мало); тип войск сохраняется; из вернувшихся 70% здоровы, 30% ранены (раненые — в госпиталь по обычным правилам). Исходной армии может уже не быть — здоровые идут в город. +- Сколько вернётся — `HospitalConfig.missingReturnPercent`, **ТЕСТ 50%** (утверждён диапазон 10–90%, формула не утверждена). Остальные в конце окна списываются (`MissingWrittenOffEvent`), места освобождаются. +- Кто и когда вернётся, решается сразу при бое (детерминированно); группы сохраняются, вернувшаяся группа удаляется — повторная загрузка или расчёт не возвращают её второй раз. +- Сохранение: `missing=`, `mgroup=`, `missingMarch=`, `missingNext=`. Окно «Войска» показывает пропавших и сколько ещё могут вернуться; окна итога боя — сколько пропало. + ## Очередь обучения Решения по 8 вопросам Саши от 31.10, п. 8: **единственный владелец очереди — Армия.** Раньше её вёл `GameProgress` (`progress.json`, время по настоящим часам) — перенесено. @@ -148,6 +159,7 @@ healNext=<номер следующей группы лечения> | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): `MissingTotal`, `Missing`, `MarkMissing`, `ProcessMissing`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`; возвращение 2–4 группами в окне 6 игровых часов после обратного пути, 70/30, списание остальных; сохранение. Доля пропавших в бою и процент возврата не утверждены (0 и 50% в таблицах). | | 04.11.2026 | Claude | `IArmy.GetAllTroops(type)` — все воины типа: в городе, в походах и раненые (для еды по типам, решения 01.11, п. 4). | | 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 3: 5 временных мест до постройки госпиталя, цена лечения 1 / 1 / 2 / 3 золота за воина. | | 01.11.2026 | Claude | Здание «Госпиталь» в городе: уровни I–V, 20–60 мест, окно с ранеными и улучшением; места Армии — через `IHospital` (`HospitalBeds`). Без госпиталя мест нет (`temporaryBeds` = 0). Тест `Hospital_BedsFromBuilding_UpgradeFreesWaiting`. | diff --git a/Docs/Modules/Battle.md b/Docs/Modules/Battle.md index a3c4109..93ccc4a 100644 --- a/Docs/Modules/Battle.md +++ b/Docs/Modules/Battle.md @@ -121,6 +121,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5): из выбывших сначала отделяются пропавшие (`MissingSharePermille`, не утверждено — 0), остальные делятся на погибших и раненых. `BattleOutcome.AttackerMissingUnits` / `DefenderMissingUnits`. Версия правил 8. | | 04.11.2026 | Claude | Решения по отчёту Саши от 01.11, п. 1: потери сильной стороны 5:1 → 10:1 плавно с 11% до 2%, дальше 2% (`strongLossPermille` 450 / 160 / 110 / 74 / 20). Версия правил 7. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): выбывшие делятся на погибших и раненых по соотношению сил стороны — 70% / 45% / 30% / 20% погибших при 1:5 / 1:2 / 1:1 / 3:1. Таблица в `BattleConfig`, версия правил 6, тест `Battle_DeadAndWounded_ShareBySideRatio`. | | 01.11.2026 | Claude | Разгром дальше 5:1 (решения 31.10, п. 5): точки 7:1 — 92% и 10:1 — 95%, дальше слабая сторона плавно стремится к 99% (`weakLossMaxPermille`). Версия правил 5. Тест кривой продлён до 30:1. | diff --git a/Docs/Modules/Core.md b/Docs/Modules/Core.md index 655b4d4..e9b21c0 100644 --- a/Docs/Modules/Core.md +++ b/Docs/Modules/Core.md @@ -61,6 +61,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | Пропавшие без вести: `MissingInfo`, `IArmy.MissingTotal/Missing/MarkMissing/ProcessMissing`, `BattleOutcome.*MissingUnits`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`, поля `Missing` у `TroopsChangedEvent` и `MarchReturnedEvent`. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. | | 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). | | 01.11.2026 | Claude | `IWorld.TryStartScout`; `IsScout` у `MarchStartedEvent` и `MarchReturnedEvent` (решения 31.10, п. 2). | diff --git a/Docs/Modules/Population.md b/Docs/Modules/Population.md index 571dd0b..251b3e2 100644 --- a/Docs/Modules/Population.md +++ b/Docs/Modules/Population.md @@ -112,6 +112,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude (Саша) | Пропавшие без вести (решения 01.11, п. 5) занимают места в населении (`Soldiers` включает `IArmy.MissingTotal`), но не едят. | | 04.11.2026 | Claude (Саша) | Решения по отчёту Саши от 01.11, п. 4: воины едят по норме типа — пехота 0,15, лучники 0,20, кавалерия 0,15, осадные 0,15 в час (`PopulationRules.FoodPerTroopTypePerHour`, `EconomyConfig.population.foodPerTroopTypePerHour`); раненые — полную норму своего типа, даже ожидая койку (`IArmy.GetAllTroops`). Новобранцы — по `foodPerSoldierPerHour`. Без таблицы — как раньше (тесты Населения не менялись). Час — тот же, что у остальной еды (переход еды на игровые часы — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Раненые считаются людьми: `Soldiers` = в городе + в походах + `IArmy.WoundedTotal` (решения 31.10, п. 7). Погибшие уходят из людей, вылеченные не появляются заново. Еда раненых — как у воинов (нормы четырёх групп — открытый вопрос). | | 01.11.2026 | Claude (Саша) | Очередь обучения перенесена в Армию (решения 31.10, п. 8). Население по-прежнему отдаёт жителей (`TryRecruit`) и принимает новобранцев в воины (`CompleteTraining`) — его код не менялся, зовёт их теперь Армия. `RecruitsTrained` больше никто не вызывает. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index b9700f9..ff55318 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 | Пропавшие без вести (решения 01.11, п. 5; доля не утверждена — выключено). Обновлены Army, Battle, Population, World, Core. | | 04.11.2026 | Claude | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. | | 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. | | 04.11.2026 | Claude | 3D-поселение, этап I — прототип на примитивах (ТЗ от 04.11). Обновлён Settlement. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 75898e6..3b3afcd 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -158,6 +158,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. | | 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. | | 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. | | 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. | From 61ba46947c06b12123ef8c788ebe7fd2b7a0726b Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 4 Nov 2026 15:21:54 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=9C2:=20=D1=86=D0=B5=D0=BB=D0=B5=D0=BD=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D0=BB=D0=B0=D0=B3=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=81=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20II=20=D0=B8=20III?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отряд идёт маршем, наблюдает 2/4 (поселение, лагерь) или 3/6 (замок) игровых часов, отчёт на конец наблюдения, потом домой. Уровень II -- точные войска, герой, стены; III -- плюс защита и ресурсы ±15%. Один отчёт на операцию, сохранение. Карта: кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту вне обзора. Co-Authored-By: Claude Opus 5.5 --- .../Modules/Core/Contracts/ModuleContracts.cs | 46 +++++++ .../Scripts/Modules/Core/Events/GameEvents.cs | 17 +++ .../Intelligence/IntelligenceService.cs | 112 ++++++++++++++++++ Assets/Scripts/Modules/Tests/ModuleTests.cs | 106 +++++++++++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 52 +++++++- .../Simulation/WorldSimulation.Patrols.cs | 2 +- .../World/Simulation/WorldSimulation.cs | 29 ++++- Assets/Scripts/WorldMap/ArmiesWindow.cs | 2 +- Assets/Scripts/WorldMap/MarchView.cs | 2 +- Assets/Scripts/WorldMap/ReconReportText.cs | 72 +++++++++++ .../Scripts/WorldMap/ReconReportText.cs.meta | 11 ++ Assets/Scripts/WorldMap/WorldMapController.cs | 102 ++++++++++++---- Docs/Modules/Core.md | 1 + Docs/Modules/Intelligence.md | 23 ++++ Docs/Modules/README.md | 1 + Docs/Modules/World.md | 1 + 16 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 Assets/Scripts/WorldMap/ReconReportText.cs create mode 100644 Assets/Scripts/WorldMap/ReconReportText.cs.meta diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index e16b134..3595ef8 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -202,6 +202,8 @@ public interface IWorld bool TryStartMarch(string targetId, int soldiers, out string error); // Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода bool TryStartScout(string targetId, int soldiers, out string error); + // Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться + bool TryStartRecon(string targetId, int soldiers, int level, out string error); } // ---------------- Мир -> Разведка: что где находится (решение №46) ---------------- @@ -216,6 +218,22 @@ public struct ArmyOnMap public int Soldiers; // точная численность -- разведка показывает игроку только примерную } +// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения +public enum ReconTargetKind { None, Settlement, Castle, Camp } + +// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта +public struct ReconTarget +{ + public string Id; + public string Name; + public ReconTargetKind Kind; + public int[] Units; // гарнизон по типам (TroopType) + public bool Fortified; // защитники за стенами + public bool HasHero; + public string[] ResourceNames; // что можно взять (имена ResourceType) + public int[] ResourceAmounts; +} + public interface IWorldMap { int HalfWidth { get; } // границы карты от центра, милли-единицы @@ -223,6 +241,8 @@ public interface IWorldMap bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока // Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта) void GetArmies(long timeMs, List result); + // Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать + bool TryGetReconTarget(string targetId, out ReconTarget target); } // ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ---------------- @@ -245,12 +265,38 @@ public struct KnownContact public long LastSeenMs; // когда видели последний раз } +// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения. +// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15% +public struct ReconReport +{ + public string Id; // устойчивый: "recon_<номер похода>" + public string TargetId; + public string TargetName; + public int Level; // 2 или 3 + public long TimeMs; // когда закончилось наблюдение (по часам игры) + public int[] Units; // гарнизон по типам на момент отчёта + public bool Fortified; + public bool HasHero; + public string Modifiers; // уровень III: основные боевые модификаторы защиты + public string[] ResourceNames; // уровень III: оценка ресурсов + public int[] ResourceMin, ResourceMax; +} + public interface IIntelligence { int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам) MapVisibility GetVisibility(int x, int y); // по последнему расчёту IReadOnlyList Contacts { get; } void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн) + + // ---- М2: целенаправленная разведка ---- + // Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III). + // 0 -- цель нельзя разведать с отчётом + long ReconDurationMs(string targetId, int level); + // Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false) + bool CompleteRecon(string reportId, string targetId, int level, long timeMs); + IReadOnlyList Reports { get; } + bool TryGetLatestReport(string targetId, out ReconReport report); } // ---------------- Боевая система: урон, потери, исход ---------------- diff --git a/Assets/Scripts/Modules/Core/Events/GameEvents.cs b/Assets/Scripts/Modules/Core/Events/GameEvents.cs index 02ae156..0401fe8 100644 --- a/Assets/Scripts/Modules/Core/Events/GameEvents.cs +++ b/Assets/Scripts/Modules/Core/Events/GameEvents.cs @@ -198,6 +198,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з public long TimeMs; } +// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10) +public struct ReconStartedEvent +{ + public string EventId; // "recon_<номер похода>" + public string TargetId; + public int Level; + public long TimeMs; +} + +public struct ReconCompletedEvent +{ + public string EventId; // = id отчёта + public string TargetId; + public int Level; + public long TimeMs; +} + public struct ArmyIdentifiedEvent // владелец чужой армии опознан { public string EventId; diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs index 520b8de..20f47cc 100644 --- a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs @@ -22,6 +22,14 @@ public sealed class IntelligenceRules public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов public int CellSize = 1000; // клетка тумана = клетка карты + // М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III) + public int[] ReconSettlementGameHours = { 2, 4 }; + public int[] ReconCastleGameHours = { 3, 6 }; + public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ) + public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут + public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15% + public int MaxReports = 30; // храним последние отчёты + public static IntelligenceRules Default => new IntelligenceRules(); } @@ -255,10 +263,105 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant get { int n = 0; foreach (var e in explored) if (e) n++; return n; } } + // ---------------- М2: целенаправленная разведка и отчёты ---------------- + + private readonly List reports = new List(); + public IReadOnlyList Reports => reports; + + public long ReconDurationMs(string targetId, int level) + { + IWorldMap map = worldMap(); + if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0; + int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours + : t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours + : t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null; + if (hours == null || hours.Length < level - 1) return 0; + return Math.Max(0, hours[level - 2]) * rules.GameHourMs; + } + + // Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false + public bool CompleteRecon(string reportId, string targetId, int level, long timeMs) + { + if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false; + IWorldMap map = worldMap(); + if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false; + level = Math.Max(2, Math.Min(3, level)); + + var report = new ReconReport + { + Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs, + Units = t.Units != null ? (int[])t.Units.Clone() : new int[4], + Fortified = t.Fortified, HasHero = t.HasHero, + Modifiers = "", ResourceNames = Array.Empty(), ResourceMin = Array.Empty(), ResourceMax = Array.Empty() + }; + if (level >= 3) + { + report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет"); + int n = t.ResourceNames != null ? t.ResourceNames.Length : 0; + report.ResourceNames = new string[n]; + report.ResourceMin = new int[n]; + report.ResourceMax = new int[n]; + for (int i = 0; i < n; i++) + { + int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0; + int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри + int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0; + report.ResourceNames[i] = t.ResourceNames[i]; + report.ResourceMin[i] = Math.Max(0, amount - shift); + report.ResourceMax[i] = report.ResourceMin[i] + width; + } + } + reports.Add(report); + while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0); + EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs }); + return true; + } + + public bool TryGetLatestReport(string targetId, out ReconReport report) + { + for (int i = reports.Count - 1; i >= 0; i--) + if (reports[i].TargetId == targetId) { report = reports[i]; return true; } + report = default; + return false; + } + + static string Csv(int[] v) + { + if (v == null) return ""; + var parts = new string[v.Length]; + for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture); + return string.Join(",", parts); + } + + static int[] ParseCsv(string s) + { + if (string.IsNullOrEmpty(s)) return Array.Empty(); + string[] parts = s.Split(','); + var v = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]); + return v; + } + + static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " "); + + void RestoreReport(string[] p) + { + if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return; + long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time); + reports.Add(new ReconReport + { + Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time, + Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8], + ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty() : p[9].Split(','), + ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11]) + }); + } + // ---------------- Сохранение (секция "intelligence") ---------------- // Простые строки (модуль без Unity): // tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex> // contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения> + // report=|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..> public string SaveKey => "intelligence"; @@ -275,6 +378,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax), c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation) })).Append('\n'); + foreach (var r in reports) + sb.Append("report=").Append(string.Join("|", new[] + { + Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture), + Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers), + string.Join(",", r.ResourceNames ?? Array.Empty()), Csv(r.ResourceMin), Csv(r.ResourceMax) + })).Append('\n'); return sb.ToString(); } @@ -289,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl; else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5)); else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|')); + else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|')); } viewDirty = true; } @@ -296,6 +407,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant public void ResetState() { contacts.Clear(); + reports.Clear(); explored = Array.Empty(); gridW = gridH = halfW = halfH = 0; lastTickMs = 0; diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index bd501aa..46ae212 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -1224,6 +1224,8 @@ public class ModuleTests return ownerId == LocalPlayer.Id; } public void GetArmies(long timeMs, List result) => result.AddRange(Armies(timeMs)); + public Dictionary Targets = new Dictionary(); + public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target); } static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) => @@ -1503,6 +1505,21 @@ public class ModuleTests x = home.x; y = home.y; return ownerId == LocalPlayer.Id; } + public bool TryGetReconTarget(string targetId, out ReconTarget target) + { + target = default; + MapNode node = World.GetNode(targetId); + if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false; + target = new ReconTarget + { + Id = node.id, Name = node.displayName, + Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement, + Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), + ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 } + }; + return true; + } + public void GetArmies(long t, List result) { foreach (var m in World.Marches) @@ -2076,4 +2093,93 @@ public class ModuleTests Assert.AreEqual(hurt, copy.WoundedTotal); Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки"); } + + // ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ---------------- + + static FakeMap ReconMap() + { + var map = new FakeMap(); + map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement, + Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } }; + map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true }; + return map; + } + + [Test] + public void Recon_DurationByTargetAndLevel() + { + var intel = Intel(ReconMap()); + const long H = 5 * 60_000; + Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа"); + Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3)); + Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6"); + Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3)); + Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели"); + Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)"); + } + + [Test] + public void Recon_ReportLevels_OncePerOperation_Saved() + { + var map = ReconMap(); + var intel = Intel(map); + var done = new List(); + EventBus.Subscribe(e => done.Add(e)); + + Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000)); + Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт"); + Assert.AreEqual(1, done.Count); + Assert.IsTrue(intel.TryGetLatestReport("city", out var r2)); + Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам"); + Assert.IsTrue(r2.Fortified); + Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов"); + Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения"); + + // Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения) + var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t; + Assert.AreEqual(10, intel.Reports[0].Units[0]); + + Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000)); + intel.TryGetLatestReport("city", out var r3); + Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции"); + Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов"); + Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна"); + Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30"); + StringAssertContains(r3.Modifiers, "стен"); + + var copy = Intel(map); + copy.Restore(intel.Capture()); + Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются"); + Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз"); + } + + static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»"); + + [Test] + public void World_ReconMarch_ObservesThenReturns() + { + const long H = 5 * 60_000; + long t0 = 1_000_000_000_000; + var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem()); + var intel = Intel(new WorldAsMap { World = world }); + MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true); + Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3))); + world.AssignUnits(m, new[] { 5, 0, 0, 0 }); + + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type); + Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают"); + Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов"); + Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _)); + MapNode boss = world.GetNode(WorldMapLayout.BossCastleId); + Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели"); + + events.Clear(); + world.Advance(m.returnMs, events); + Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт"); + Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой"); + Assert.AreEqual(5, m.survivors, "разведка без потерь"); + Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs)); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index 8eb22bc..e6946bd 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -28,7 +28,8 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant public event Action OnBattleResolved; public event Action OnMarchReturned; public event Action OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет) - public event Action OnScoutArrived; // разведка дошла до цели и повернула домой + public event Action OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение) + public event Action OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов // Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы. // Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера. @@ -113,6 +114,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March); else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March); else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March); + else if (e.Type == WorldEventType.ReconCompleted) HandleReconCompleted(e.March); else if (e.Type == WorldEventType.GatherFinished) Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой"); } @@ -173,6 +175,28 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return false; } + // Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять + public bool TryGetReconTarget(string targetId, out ReconTarget target) + { + target = default; + MapNode node = World.GetNode(targetId); + if (node == null) return false; + ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle + : node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement + : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None; + if (kind == ReconTargetKind.None) return false; + var names = new string[node.rewards.Count]; + var amounts = new int[node.rewards.Count]; + for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; } + target = new ReconTarget + { + Id = node.id, Name = node.displayName, Kind = kind, + Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет + ResourceNames = names, ResourceAmounts = amounts + }; + return true; + } + // Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера) public void GetArmies(long timeMs, List result) { @@ -194,7 +218,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return TryStart(targetId, soldiers, true, out error); } - bool TryStart(string targetId, int soldiers, bool scout, out string error) + // Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки + public bool TryStartRecon(string targetId, int soldiers, int level, out string error) + { + if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; } + if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; } + long hold = intel.ReconDurationMs(targetId, level); + if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; } + return TryStart(targetId, soldiers, true, out error, level, hold); + } + + bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0) { if (!GameServices.TryGet(out IArmy army)) { @@ -218,6 +252,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); if (march == null) return false; + if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs); // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. @@ -234,9 +269,22 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}"); OnMarchStarted?.Invoke(march); EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout }); + if (reconLevel > 0) + EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs }); return true; } + static string ReportId(MarchData march) => "recon_" + march.id; + + // Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход) + void HandleReconCompleted(MarchData march) + { + if (GameServices.TryGet(out IIntelligence intel)) + intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs); + Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}"); + OnReconCompleted?.Invoke(march); + } + void HandleBattleResolved(MarchData march) { // Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs index 9c2d204..11ae9c1 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs @@ -169,7 +169,7 @@ namespace Bogatyri.World return true; } - long backStart = m.gather ? m.gatherEndMs : m.arriveMs; + long backStart = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors; if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет if (m.state == MarchState.Gathering || t < backStart) diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index d61ef03..44df21e 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -30,6 +30,8 @@ namespace Bogatyri.World public bool gather; // поход на ресурсную точку: сбор вместо боя public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора + public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт + public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя @@ -147,7 +149,8 @@ namespace Bogatyri.World MarchReturned, // войско вернулось домой GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty) GatherFinished, // сбор закончен, войско идёт домой с грузом - ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет) + ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет) + ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой } public struct WorldEvent @@ -373,6 +376,11 @@ namespace Bogatyri.World ArriveScout(next); events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs }); } + else if (nextKind == 1) + { + next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан + events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs }); + } else { next.state = MarchState.Finished; @@ -487,6 +495,23 @@ namespace Bogatyri.World m.loot = new List(); ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null; m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active); + + // М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения) + if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty) + { + m.state = MarchState.Gathering; + m.gatherEndMs = m.arriveMs + m.reconHoldMs; + m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units); + } + } + + // Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели + public bool SetRecon(MarchData m, int level, long holdMs) + { + if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false; + m.reconLevel = level; + m.reconHoldMs = Math.Max(0, holdMs); + return true; } // Название цели похода: запомненное при выступлении (старые походы -- с карты) @@ -542,7 +567,7 @@ namespace Bogatyri.World { segmentFrom = target; segmentTo = home; - start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора + start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца end = m.returnMs; } diff --git a/Assets/Scripts/WorldMap/ArmiesWindow.cs b/Assets/Scripts/WorldMap/ArmiesWindow.cs index 888a667..4bfb201 100644 --- a/Assets/Scripts/WorldMap/ArmiesWindow.cs +++ b/Assets/Scripts/WorldMap/ArmiesWindow.cs @@ -118,7 +118,7 @@ public static class ArmiesWindow timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}"; break; case MarchState.Gathering: - action = $"собирает: {target}"; + action = m.scout ? $"наблюдает за: {target} (разведка {(m.reconLevel == 3 ? "III" : "II")})" : $"собирает: {target}"; timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}"; break; default: diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index a0f22af..4127770 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -125,7 +125,7 @@ public class MarchView : MonoBehaviour long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs; long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") - : gathering ? $"сбор {march.cargo}" + : gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}") : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» string time = secondsLeft >= 3600 diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs b/Assets/Scripts/WorldMap/ReconReportText.cs new file mode 100644 index 0000000..ef83f91 --- /dev/null +++ b/Assets/Scripts/WorldMap/ReconReportText.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using TMPro; +using UnityEngine; + +// Отчёты разведки на экране (Разведка М2): текст отчёта и окно «Отчёты» с последними отчётами. +// Сами отчёты хранит модуль Разведки (IIntelligence.Reports) -- здесь только показ. +// Сведения -- на момент отчёта: сколько прошло с тех пор, видно в каждом отчёте. +public static class ReconReportText +{ + static readonly string[] TroopNames = { "пехота", "лучники", "кавалерия", "осадные" }; + static readonly Dictionary ResourceNames = new Dictionary + { + { "Food", "еда" }, { "Wood", "дерево" }, { "Stone", "камень" }, { "Iron", "металл" }, { "Gold", "золото" }, { "Gems", "кристаллы" } + }; + + public static string Format(ReconReport r, long nowMs) + { + var lines = new List + { + $"{r.TargetName} — разведка {(r.Level == 3 ? "III" : "II")}, {Ago(nowMs - r.TimeMs)} назад", + }; + int total = 0; + var parts = new List(); + for (int i = 0; r.Units != null && i < r.Units.Length; i++) + { + total += r.Units[i]; + if (r.Units[i] > 0) parts.Add($"{TroopNames[i % TroopNames.Length]} {r.Units[i]}"); + } + lines.Add($"Гарнизон: {total}" + (parts.Count > 0 ? $" ({string.Join(", ", parts)})" : "")); + lines.Add((r.Fortified ? "Защитники за стенами" : "Стен нет, бой в поле") + (r.HasHero ? ", есть герой" : ", героя нет")); + if (r.Level >= 3) + { + if (!string.IsNullOrEmpty(r.Modifiers)) lines.Add("Защита: " + r.Modifiers); + var res = new List(); + for (int i = 0; r.ResourceNames != null && i < r.ResourceNames.Length; i++) + { + string name = ResourceNames.TryGetValue(r.ResourceNames[i], out var n) ? n : r.ResourceNames[i]; + res.Add($"{name} ~{r.ResourceMin[i]}–{r.ResourceMax[i]}"); + } + if (res.Count > 0) lines.Add("Ресурсы (оценка ±15%): " + string.Join(", ", res)); + } + return string.Join("\n", lines); + } + + static string Ago(long ms) + { + long min = System.Math.Max(0, ms) / 60_000; + if (min < 1) return "только что"; + return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин"; + } + + // Окно «Отчёты»: последние отчёты, новые сверху + public static void OpenWindow() + { + var card = UIKit.Modal("ОТЧЁТЫ РАЗВЕДКИ", out GameObject root); + if (!GameServices.TryGet(out IIntelligence intel) || intel.Reports.Count == 0) + { + UIKit.Text(card, "Отчётов пока нет. Нажмите на город, лагерь или замок и отправьте «Разведку II» или «Разведку III».", + 26, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + else + { + long now = MarchManager.NowMs; + int shown = 0; + for (int i = intel.Reports.Count - 1; i >= 0 && shown < 5; i--, shown++) + UIKit.Text(card, Format(intel.Reports[i], now), 24, UIKit.Theme.Text, TextAlignmentOptions.Left); + if (intel.Reports.Count > 5) + UIKit.Text(card, $"И ещё {intel.Reports.Count - 5} более старых", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28); + } +} diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs.meta b/Assets/Scripts/WorldMap/ReconReportText.cs.meta new file mode 100644 index 0000000..aaa2129 --- /dev/null +++ b/Assets/Scripts/WorldMap/ReconReportText.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76599837523e444e87459016c4f367a4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 3b88b8d..5f9d0d6 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -90,6 +90,7 @@ public class WorldMapController : MonoBehaviour private string attackInfoBase; private BattleCondition shownConditions; private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось + private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта) WorldSimulation World => MarchManager.Instance.World; @@ -129,6 +130,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnBattleResolved += HandleBattleResolved; MarchManager.Instance.OnGatherStarted += HandleGatherStarted; MarchManager.Instance.OnScoutArrived += HandleScoutArrived; + MarchManager.Instance.OnReconCompleted += HandleReconCompleted; MarchManager.Instance.OnMarchReturned += HandleMarchReturned; } @@ -138,6 +140,7 @@ public class WorldMapController : MonoBehaviour MarchManager.Instance.OnBattleResolved -= HandleBattleResolved; MarchManager.Instance.OnGatherStarted -= HandleGatherStarted; MarchManager.Instance.OnScoutArrived -= HandleScoutArrived; + MarchManager.Instance.OnReconCompleted -= HandleReconCompleted; MarchManager.Instance.OnMarchReturned -= HandleMarchReturned; } @@ -169,39 +172,68 @@ public class WorldMapController : MonoBehaviour var button = copy.GetComponent