From 538dbea222394d7f0502d1cddf7793e61405bdb9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:42:03 +0100 Subject: [PATCH 1/3] =?UTF-8?q?3D:=20=D0=B3=D0=B5=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=83=20=D0=A2=D0=B5=D1=80=D0=B5=D0=BC=D0=B0=20--=20=D0=B3?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BF=D0=BE=20=D1=81=D0=B2?= =?UTF-8?q?=D0=BE=D0=B1=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=20=D0=BA=D0=BB=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=BC=20=D1=80=D1=8F=D0=B4=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=81=20=D0=A2=D0=B5=D1=80=D0=B5=D0=BC=D0=BE=D0=BC,=20=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=20--=20=D1=84=D1=80=D0=B0=D0=B7=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B3=D0=B5=D1=80=D0=BE=D1=8F=20(?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D0=B8=D0=B7=202D)?= 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 | 28 ++++ Assets/Scripts/City3D/City3DHero.cs | 186 ++++++++++++++++++++++ Assets/Scripts/City3D/City3DHero.cs.meta | 11 ++ Docs/Modules/Settlement.md | 1 + 4 files changed, 226 insertions(+) create mode 100644 Assets/Scripts/City3D/City3DHero.cs create mode 100644 Assets/Scripts/City3D/City3DHero.cs.meta diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index a151365..87e5da9 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -828,6 +828,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive if (!stateApplied && map != null) ApplySaved(); if (cam == null) return; UpdateConstructionViews(); + UpdateHero(); bool down, held, up; Vector3 pos; if (Input.touchCount > 0) @@ -898,6 +899,12 @@ public class City3DController : MonoBehaviour, City3DSave.ILive void Tap(Vector3 pos) { + // Тап по герою -- фраза и окно героя (как в 2D) + if (mode == Mode.View && City3DHero.Instance != null && !City3DHero.Instance.Hidden) + { + foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(pos), 500f)) + if (hit.collider.GetComponentInParent() != null) { City3DHero.Instance.OnTapped(); return; } + } if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return; if (mode == Mode.Place || mode == Mode.Move) { @@ -1015,6 +1022,27 @@ public class City3DController : MonoBehaviour, City3DSave.ILive float guiScale = 1f; GUIStyle button, label, box; + // ---------------- Герой у Терема (перенос из 2D) ---------------- + // Терем достроен -- у его ворот герой; Терема нет -- героя нет. В режиме стройки и переноса прячется + + void UpdateHero() + { + if (!stateApplied) return; + string castleId = null; + foreach (var pair in buildingType) + { + if (Types[pair.Value].Name != "Терем") continue; + if (!buildingViews.TryGetValue(pair.Key, out var view) || view == null || BuildTimer.IsConstructing(view.transform)) continue; + castleId = pair.Key; + break; + } + var hero = City3DHero.Instance; + if (castleId == null) { if (hero != null) Destroy(hero.gameObject); return; } + if (hero == null || hero.CastleId != castleId || hero.CastleView == null) + hero = City3DHero.Spawn(transform, map, castleId, buildingViews[castleId], () => townHallLevel); + hero.Hidden = mode == Mode.Place || mode == Mode.Move; + } + // ---------------- Стройка по времени в 3D ---------------- // BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым // (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11) diff --git a/Assets/Scripts/City3D/City3DHero.cs b/Assets/Scripts/City3D/City3DHero.cs new file mode 100644 index 0000000..d4ec7a6 --- /dev/null +++ b/Assets/Scripts/City3D/City3DHero.cs @@ -0,0 +1,186 @@ +using Bogatyri.Settlement.Placement; +using UnityEngine; + +// ГЕРОЙ В 3D-ПОСЕЛЕНИИ (перенос из 2D, решение 05.11: всё из 2D-города -- в 3D). Как HeroWalker в 2D-городе: +// - живёт у Терема богатыря: появляется у его ворот, когда Терем достроен, и исчезает, если Терема нет; +// - гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше WalkRadius от Терема: +// шаг -- на соседнюю свободную клетку, поэтому сквозь здания и деревья не проходит; после нескольких шагов стоит 1-3 с; +// - тап по герою -- фраза и окно героя (окно Терема); в режиме стройки и переноса прячется; +// - пока без арта: фигурка из простых форм (тело, голова, шлем, щит, копьё). Арт подставим в BuildFigure. +// Только оживление города: на экономику, бой и сохранение не влияет. Создаёт и убирает City3DController. +public class City3DHero : MonoBehaviour +{ + public static City3DHero Instance { get; private set; } + + const float Speed = 1.3f; // клеток в секунду + const int WalkRadius = 6; // насколько далеко уходит от Терема + + static readonly string[] Phrases = + { + "Город под защитой!", + "Веди, воевода, — не подведу.", + "Силушка богатырская при мне.", + "Дозором обхожу владения.", + "Есть ли где ворог? Только скажи.", + }; + + SettlementMap map; + System.Func townHallLevel; + public string CastleId { get; private set; } + public GameObject CastleView { get; private set; } + + Transform figure; + Vector2Int cell; // клетка, к которой идёт (или на которой стоит) + Vector2Int previous; // откуда пришёл -- чтобы не топтаться туда-обратно + int stepsLeft; + float waitLeft; + + // Видимость: в режиме стройки и переноса герой прячется, чтобы не мешать ставить здания + public bool Hidden + { + get => figure != null && !figure.gameObject.activeSelf; + set { if (figure != null && figure.gameObject.activeSelf == value) figure.gameObject.SetActive(!value); } + } + + public static City3DHero Spawn(Transform parent, SettlementMap map, string castleId, GameObject castleView, System.Func townHallLevel) + { + if (Instance != null) Destroy(Instance.gameObject); + var go = new GameObject("Hero3D"); + go.transform.SetParent(parent, false); + var h = go.AddComponent(); + h.map = map; + h.CastleId = castleId; + h.CastleView = castleView; + h.townHallLevel = townHallLevel; + h.BuildFigure(); + h.cell = h.previous = h.Gate(); + go.transform.position = CellWorld(h.cell); + h.waitLeft = 1f; + Instance = h; + return h; + } + + void OnDestroy() + { + if (Instance == this) Instance = null; + } + + // ---------------- Внешний вид (заглушка) ---------------- + + void BuildFigure() + { + figure = new GameObject("Figure").transform; + figure.SetParent(transform, false); + Part(PrimitiveType.Capsule, new Vector3(0, 0.32f, 0), new Vector3(0.28f, 0.3f, 0.22f), new Color(0.55f, 0.4f, 0.2f)); // кафтан + Part(PrimitiveType.Sphere, new Vector3(0, 0.72f, 0), new Vector3(0.2f, 0.2f, 0.2f), new Color(1f, 0.85f, 0.7f)); // голова + Part(PrimitiveType.Sphere, new Vector3(0, 0.8f, 0), new Vector3(0.22f, 0.14f, 0.22f), new Color(0.75f, 0.75f, 0.8f)); // шлем + Part(PrimitiveType.Cylinder, new Vector3(0, 0.9f, 0), new Vector3(0.03f, 0.06f, 0.03f), new Color(0.75f, 0.75f, 0.8f)); // шишак + Part(PrimitiveType.Cube, new Vector3(0.17f, 0.36f, 0.04f), new Vector3(0.04f, 0.26f, 0.2f), new Color(0.8f, 0.15f, 0.15f)); // щит + Part(PrimitiveType.Cylinder, new Vector3(-0.17f, 0.5f, 0), new Vector3(0.025f, 0.5f, 0.025f), new Color(0.45f, 0.3f, 0.15f)); // копьё + Part(PrimitiveType.Cylinder, new Vector3(0, 0.005f, 0), new Vector3(0.36f, 0.005f, 0.36f), new Color(0.18f, 0.22f, 0.14f)); // тень + + // Тап по герою: коллайдер чуть больше фигурки (City3DController ищет City3DHero под пальцем) + var hit = gameObject.AddComponent(); + hit.center = new Vector3(0, 0.5f, 0); + hit.size = new Vector3(0.7f, 1.1f, 0.7f); + } + + void Part(PrimitiveType type, Vector3 pos, Vector3 scale, Color color) + { + var p = GameObject.CreatePrimitive(type); + Destroy(p.GetComponent()); + p.transform.SetParent(figure, false); + p.transform.localPosition = pos; + p.transform.localScale = scale; + p.GetComponent().sharedMaterial = Map3D.Mat(color); + } + + // ---------------- Прогулка по клеткам ---------------- + + static Vector3 CellWorld(Vector2Int c) + { + float x = c.x + 0.5f, z = c.y + 0.5f; + return new Vector3(x, City3DLayout.HeightAt(x, z), z); + } + + bool Walkable(int x, int y) + { + if (map == null || !map.InMap(x, y)) return false; + if (map.KindAt(x, y) != CellKind.Ground || !map.IsOpen(x, y, townHallLevel != null ? townHallLevel() : 1)) return false; + return map.BuildingAt(x, y) == null && map.ObstacleAt(x, y) == null; + } + + bool CastleRect(out PlacedRect r) + { + r = default; + return map != null && map.TryGetBuilding(CastleId, out r); + } + + bool NearHome(int x, int y) + { + if (!CastleRect(out var r)) return true; + float cx = r.X + r.W / 2f, cy = r.Y + r.H / 2f; + return Mathf.Abs(x + 0.5f - cx) <= WalkRadius + r.W / 2f && Mathf.Abs(y + 0.5f - cy) <= WalkRadius + r.H / 2f; + } + + // Ворота: ближайшая свободная клетка вокруг Терема (с нижней стороны -- первой) + Vector2Int Gate() + { + if (!CastleRect(out var r)) return Vector2Int.zero; + for (int ring = 1; ring <= WalkRadius; ring++) + for (int x = r.X - ring; x < r.X + r.W + ring; x++) + foreach (int y in new[] { r.Y - ring, r.Y + r.H - 1 + ring }) + if (Walkable(x, y)) return new Vector2Int(x, y); + return new Vector2Int(r.X + r.W / 2, r.Y - 1); + } + + static readonly Vector2Int[] Dirs = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right }; + + // Следующая клетка: соседняя свободная рядом с Теремом; назад -- только если больше некуда + Vector2Int NextCell() + { + var options = new System.Collections.Generic.List(); + foreach (var d in Dirs) + { + var c = cell + d; + if (c != previous && Walkable(c.x, c.y) && NearHome(c.x, c.y)) options.Add(c); + } + if (options.Count == 0 && Walkable(previous.x, previous.y)) return previous; + return options.Count > 0 ? options[Random.Range(0, options.Count)] : cell; + } + + void Update() + { + if (Hidden) return; + if (!Walkable(cell.x, cell.y) && cell != Gate()) { cell = previous = Gate(); transform.position = CellWorld(cell); } // на клетку поставили здание + if (waitLeft > 0f) { waitLeft -= Time.deltaTime; figure.localPosition = Vector3.zero; return; } + + Vector3 goal = CellWorld(cell), pos = transform.position; + Vector3 flat = new Vector3(goal.x - pos.x, 0, goal.z - pos.z); + if (flat.magnitude < 0.03f) + { + if (stepsLeft <= 0) { stepsLeft = Random.Range(3, 7); waitLeft = Random.Range(1f, 3f); return; } + stepsLeft--; + var next = NextCell(); + previous = cell; + cell = next; + return; + } + Vector3 step = flat.normalized * Speed * Time.deltaTime; + if (step.magnitude > flat.magnitude) step = flat; + pos += step; + pos.y = City3DLayout.HeightAt(pos.x, pos.z); // по земле, через холмы + transform.position = pos; + transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(flat), Time.deltaTime * 10f); // смотрит, куда идёт + figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.04f, 0f); // покачивание при ходьбе + } + + // ---------------- Тап ---------------- + + public void OnTapped() + { + UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]); + waitLeft = 2f; // остановился поговорить + if (CastleView != null) BuildingWindow.Open(CastleView); + } +} diff --git a/Assets/Scripts/City3D/City3DHero.cs.meta b/Assets/Scripts/City3D/City3DHero.cs.meta new file mode 100644 index 0000000..04dbf83 --- /dev/null +++ b/Assets/Scripts/City3D/City3DHero.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d4c2a7e1f5b4e93b6a1c0d9e2f73a58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 0981039..90567c3 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -119,6 +119,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Герой у Терема в 3D (перенос из 2D): `City3D/City3DHero.cs` — фигурка из простых форм появляется у ворот достроенного Терема и гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше 6 клеток от Терема, шагами на соседнюю клетку — сквозь здания не проходит; тап — фраза и окно героя; в режиме стройки и переноса прячется. Создаёт и убирает `City3DController.UpdateHero`. На экономику и сохранение не влияет. | | 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. | | 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. | | 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. | -- 2.54.0 From af7150a0065132717d04cca4a97b45a0c2a8c6fe Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:55:59 +0100 Subject: [PATCH 2/3] =?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=80=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B2=D0=B8=D0=B6=D1=83=D1=89=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B0=D1=80=D0=BC=D0=B8=D0=B8=20--=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=85=D0=B2=D0=B0=D1=82,=20=D1=81=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5,?= =?UTF-8?q?=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20II/III=20(1/2=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=BE=D0=B2=D1=8B=D1=85=20=D1=87=D0=B0=D1=81=D0=B0?= =?UTF-8?q?),=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Мир: поход на армию (targetMarchId), точка перехвата по маршруту армии и скорости отряда, наблюдение рядом с ней, домой от места конца наблюдения; не догнать -- поход не начинается - Разведка: ReconTargetKind.Army, ReconArmyGameHours {1, 2}; отчёт -- точный состав армии - Карта: тап по чужой армии -- окно «ЧУЖАЯ АРМИЯ» с планом и кнопками «Разведка II / III» - 4 теста Co-Authored-By: Claude Opus 5.5 --- .../Modules/Core/Contracts/ModuleContracts.cs | 2 +- .../Intelligence/IntelligenceService.cs | 4 +- Assets/Scripts/Modules/Tests/ModuleTests.cs | 100 +++++++++++ Assets/Scripts/Modules/World/MarchManager.cs | 59 +++++- .../Simulation/WorldSimulation.ArmyRecon.cs | 169 ++++++++++++++++++ .../WorldSimulation.ArmyRecon.cs.meta | 11 ++ .../Simulation/WorldSimulation.Patrols.cs | 1 + .../World/Simulation/WorldSimulation.cs | 7 +- .../Scripts/WorldMap/IntelligenceOverlay.cs | 10 +- Assets/Scripts/WorldMap/MarchView.cs | 6 +- Assets/Scripts/WorldMap/ReconReportText.cs | 35 ++++ Assets/Scripts/WorldMap/WorldMapController.cs | 21 ++- Docs/Modules/Intelligence.md | 5 +- Docs/Modules/World.md | 1 + 14 files changed, 416 insertions(+), 15 deletions(-) create mode 100644 Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs create mode 100644 Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs.meta diff --git a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs index 2b18f13..754ed7a 100644 --- a/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs +++ b/Assets/Scripts/Modules/Core/Contracts/ModuleContracts.cs @@ -231,7 +231,7 @@ public struct ArmyOnMap } // Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения -public enum ReconTargetKind { None, Settlement, Castle, Camp } +public enum ReconTargetKind { None, Settlement, Castle, Camp, Army } // Army -- движущаяся армия (id "march_<номер>") // Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта public struct ReconTarget diff --git a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs index 20f47cc..24c44d3 100644 --- a/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs +++ b/Assets/Scripts/Modules/Intelligence/IntelligenceService.cs @@ -26,6 +26,7 @@ public sealed class IntelligenceRules public int[] ReconSettlementGameHours = { 2, 4 }; public int[] ReconCastleGameHours = { 3, 6 }; public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ) + public int[] ReconArmyGameHours = { 1, 2 }; // вражеская армия (ТЗ, раздел 6): II -- 1, III -- 2 игровых часа public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15% public int MaxReports = 30; // храним последние отчёты @@ -274,7 +275,8 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant 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; + : t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours + : t.Kind == ReconTargetKind.Army ? rules.ReconArmyGameHours : null; if (hours == null || hours.Length < level - 1) return 0; return Math.Max(0, hours[level - 2]) * rules.GameHourMs; } diff --git a/Assets/Scripts/Modules/Tests/ModuleTests.cs b/Assets/Scripts/Modules/Tests/ModuleTests.cs index 46ae212..6e80f6e 100644 --- a/Assets/Scripts/Modules/Tests/ModuleTests.cs +++ b/Assets/Scripts/Modules/Tests/ModuleTests.cs @@ -2182,4 +2182,104 @@ public class ModuleTests Assert.AreEqual(5, m.survivors, "разведка без потерь"); Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs)); } + + // ---------------- Разведка М2: движущаяся армия (ТЗ разведки, раздел 6: II -- 1, III -- 2 игровых часа) ---------------- + + // Медленная чужая армия с долгим маршрутом рядом с игроком (настоящие патрули идут ~40 мин -- их часто не догнать, + // и это правильно: разведать можно армию, которая проходит рядом). Новые патрули не выходят: место занято ею + static WorldSimulation ArmyReconWorld(long t0, out MarchData patrol) + { + var world = PatrolWorld(true); + world.Patrols = new PatrolRules { Enabled = true, MaxActive = 1, IntervalMs = 24 * 3_600_000L }; + patrol = new MarchData + { + id = 500, ownerId = "npc_test", fromNodeId = "city_14", targetNodeId = "city_15", soldiers = 20, survivors = 20, + departMs = t0, arriveMs = t0 + 10 * 3_600_000L, returnMs = t0 + 20 * 3_600_000L, state = MarchState.Outbound, patrol = true + }; + world.LoadMarches(new List { patrol }); + return world; + } + + [Test] + public void ArmyRecon_Duration_1and2GameHours() + { + var map = new FakeMap(); + map.Targets["march_7"] = new ReconTarget { Id = "march_7", Name = "Армия", Kind = ReconTargetKind.Army, Units = new[] { 20, 0, 0, 0 } }; + var intel = Intel(map); + const long H = 5 * 60_000; + Assert.AreEqual(1 * H, intel.ReconDurationMs("march_7", 2), "вражеская армия, уровень II -- 1 игровой час"); + Assert.AreEqual(2 * H, intel.ReconDurationMs("march_7", 3), "уровень III -- 2 игровых часа"); + } + + [Test] + public void ArmyRecon_Intercepts_FollowsTarget_ReportsThenReturns() + { + const long H = 5 * 60_000; + long t0 = 1_000_000_000_000; + var world = ArmyReconWorld(t0, out MarchData patrol); + long start = t0 + 60_000; + MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, start, 2, H, out string error); + Assert.IsNotNull(m, error); + Assert.IsTrue(WorldSimulation.IsArmyRecon(m)); + world.AssignUnits(m, new[] { 5, 0, 0, 0 }); + Assert.IsTrue(m.arriveMs > start, "путь до перехвата -- физический, не мгновенный"); + Assert.AreEqual(m.arriveMs + H, m.gatherEndMs, "наблюдение -- 1 игровой час"); + + // Точка перехвата -- там, где армия в момент перехвата + world.TryGetPositionAt(patrol, m.arriveMs, out int px, out int py, out _, out _, out _); + Assert.AreEqual(px, m.meetX); Assert.AreEqual(py, m.meetY); + + var events = new List(); + world.Advance(m.arriveMs, events); + Assert.AreEqual(WorldEventType.ScoutArrived, events[events.Count - 1].Type); + Assert.AreEqual(MarchState.Gathering, m.state, "догнали -- наблюдают"); + + // Во время наблюдения разведчики идут рядом с армией + long mid = m.arriveMs + H / 2; + world.TryGetPositionAt(patrol, mid, out int ax, out int ay, out _, out _, out _); + world.TryGetPositionAt(m, mid, out int sx, out int sy, out _, out _, out _); + Assert.AreEqual(ax, sx); Assert.AreEqual(ay, sy); + + events.Clear(); + world.Advance(m.returnMs, events); + var mine = events.FindAll(e => e.March == m); + Assert.AreEqual(WorldEventType.ReconCompleted, mine[0].Type, "сначала отчёт"); + Assert.AreEqual(m.gatherEndMs, mine[0].TimeMs, "отчёт -- на конец наблюдения"); + Assert.AreEqual(WorldEventType.MarchReturned, mine[1].Type, "потом -- домой"); + Assert.AreEqual(5, m.survivors, "разведка без боя и потерь"); + Assert.IsTrue(m.returnMs > m.gatherEndMs, "домой идут пешком от места, где кончилось наблюдение"); + } + + [Test] + public void ArmyRecon_Refused_IfArmyLeavesBeforeObservationEnds_OrIsOwn() + { + const long H = 5 * 60_000; + long t0 = 1_000_000_000_000; + var world = ArmyReconWorld(t0, out MarchData patrol); + int before = world.Marches.Count; + // Армия почти дома: наблюдение на 2 часа не успеть + long late = patrol.returnMs - 30_000; + Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, late, 3, 2 * H, out string err)); + Assert.IsTrue(err.Contains("не догнать") || err.Contains("раньше"), err); + Assert.AreEqual(before, world.Marches.Count, "поход не создан"); + Assert.IsNull(world.StartArmyRecon(patrol.ownerId, WorldMapLayout.PlayerCityId, patrol.id, 5, t0, 2, H, out err), "свою армию не разведывают"); + Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, 99999, 5, t0, 2, H, out err), "нет такой армии"); + } + + [Test] + public void ArmyRecon_TargetGone_BeforeIntercept_ReturnsWithoutReport() + { + const long H = 5 * 60_000; + long t0 = 1_000_000_000_000; + var world = ArmyReconWorld(t0, out MarchData patrol); + MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, t0 + 1000, 2, H, out string error); + Assert.IsNotNull(m, error); + world.Patrols = new PatrolRules { Enabled = false }; // патрули выключили -- цель пропала + var events = new List(); + world.SpawnPatrolsUntil(t0 + 2000); + world.Advance(m.returnMs + 24 * 3_600_000L, events); + Assert.IsTrue(m.targetEmpty, "цели нет -- дошли до места и вернулись"); + Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта"); + Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись"); + } } diff --git a/Assets/Scripts/Modules/World/MarchManager.cs b/Assets/Scripts/Modules/World/MarchManager.cs index e6946bd..56a9570 100644 --- a/Assets/Scripts/Modules/World/MarchManager.cs +++ b/Assets/Scripts/Modules/World/MarchManager.cs @@ -179,6 +179,21 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant public bool TryGetReconTarget(string targetId, out ReconTarget target) { target = default; + // Движущаяся армия ("march_<номер>", как в GetArmies): состав -- на этот момент, без стен и ресурсов + if (TryParseArmyId(targetId, out int marchId)) + { + MarchData m = World.FindMarch(marchId); + if (m == null || m.ownerId == LocalPlayerId) return false; + int[] units = m.units != null && m.units.Length == TroopType.Count ? (int[])m.units.Clone() : new[] { m.soldiers, 0, 0, 0 }; + MapNode home = World.GetNode(m.fromNodeId); + target = new ReconTarget + { + Id = targetId, Name = home != null ? $"Армия из «{home.displayName}»" : "Чужая армия", Kind = ReconTargetKind.Army, + Units = units, Fortified = false, HasHero = false, // героев пока нет + ResourceNames = new string[0], ResourceAmounts = new int[0] + }; + return true; + } MapNode node = World.GetNode(targetId); if (node == null) return false; ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle @@ -228,7 +243,39 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant 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) + public static bool TryParseArmyId(string id, out int marchId) + { + marchId = 0; + return id != null && id.StartsWith("march_") && int.TryParse(id.Substring(6), out marchId) && marchId > 0; + } + + // Цель отчёта разведки этого похода: армия ("march_<номер>") или узел карты + public static string ReconTargetId(MarchData march) => + WorldSimulation.IsArmyRecon(march) ? "march_" + march.targetMarchId : march.targetNodeId; + + // Разведка движущейся армии (Разведка М2, ТЗ раздел 6): отряд догоняет армию, идёт рядом время наблюдения, + // приносит отчёт и возвращается. armyId -- как в GetArmies ("march_<номер>") + public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error) + { + if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; } + if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; } + if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; } + long hold = intel.ReconDurationMs(armyId, level); + if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; } + return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId); + } + + // План разведки армии без старта: когда догонят, когда отчёт, когда дома (как для пехоты -- состав ещё не выбран) + public bool TryPlanArmyRecon(string armyId, int level, out long meetMs, out long reportMs, out long homeMs, out string error) + { + meetMs = reportMs = homeMs = 0; + if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; } + long hold = GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(armyId, level) : 0; + if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; } + return World.TryPlanArmyRecon(WorldMapLayout.PlayerCityId, targetMarchId, NowMs, hold, out meetMs, out reportMs, out homeMs, out error); + } + + bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0) { if (!GameServices.TryGet(out IArmy army)) { @@ -250,9 +297,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant return false; } - MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); + MarchData march = targetMarchId > 0 + ? World.StartArmyRecon(LocalPlayerId, WorldMapLayout.PlayerCityId, targetMarchId, soldiers, NowMs, reconLevel, reconHoldMs, out error) + : World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); if (march == null) return false; - if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs); + if (reconLevel > 0 && targetMarchId == 0) World.SetRecon(march, reconLevel, reconHoldMs); // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. @@ -266,7 +315,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant World.AssignUnits(march, army.GetMarchUnits(march.id)); if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); - Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}"); + Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.TargetName(march)}"); OnMarchStarted?.Invoke(march); EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout }); if (reconLevel > 0) @@ -280,7 +329,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant void HandleReconCompleted(MarchData march) { if (GameServices.TryGet(out IIntelligence intel)) - intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs); + intel.CompleteRecon(ReportId(march), ReconTargetId(march), march.reconLevel, march.gatherEndMs); Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}"); OnReconCompleted?.Invoke(march); } diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs new file mode 100644 index 0000000..c288cc5 --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs @@ -0,0 +1,169 @@ +using System; + +namespace Bogatyri.World +{ + // РАЗВЕДКА ДВИЖУЩЕЙСЯ АРМИИ (Разведка М2, ТЗ разведки, раздел 6: «вражеская армия -- 1 / 2 игровых часа»). + // Весь путь -- физический марш, без телепорта и без постоянного слежения: + // 1) отряд идёт из города к точке перехвата -- где армия окажется, когда отряд до неё дойдёт (по маршруту армии + // и скорости отряда, с шагом InterceptStepMs); + // 2) наблюдает reconHoldMs, идя рядом с армией (позиция отряда = позиция армии); + // 3) отчёт на конец наблюдения, затем домой из места, где наблюдение кончилось. + // Всё время похода известно при выступлении (маршрут армии известен Миру), поэтому офлайн считается так же. + // Догнать нельзя или армия уйдёт с карты раньше конца наблюдения -- поход не начинается (ошибка для игрока). + // Армия-цель исчезла до перехвата (патрули выключили) -- отряд дошёл до точки и вернулся (targetEmpty). + public sealed partial class WorldSimulation + { + public const long InterceptStepMs = 10_000; + + // Поход ли это на армию (а не на узел карты) + public static bool IsArmyRecon(MarchData m) => m != null && m.targetMarchId > 0; + + public MarchData FindMarch(int id) => marches.Find(m => m.id == id); + + // Начать разведку армии. Состав ещё неизвестен -- план как для пехоты; AssignUnits пересчитает по составу + public MarchData StartArmyRecon(string ownerId, string fromId, int targetMarchId, int soldiers, long nowMs, int level, long holdMs, out string error) + { + MapNode from = GetNode(fromId); + MarchData target = FindMarch(targetMarchId); + if (from == null) { error = "Неизвестная точка на карте"; return null; } + if (target == null || !TryGetPositionAt(target, nowMs, out _, out _, out _, out _, out _)) { error = "Этой армии уже нет на карте"; return null; } + if (target.ownerId == ownerId) { error = "Это ваша армия"; return null; } + if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; } + if (level < 2 || level > 3 || holdMs <= 0) { error = "Уровень разведки -- II или III"; return null; } + + var march = new MarchData + { + id = nextMarchId++, + ownerId = ownerId, + fromNodeId = fromId, + targetNodeId = target.targetNodeId, // узел, куда идёт армия (для старого кода, которому нужен узел) + targetMarchId = targetMarchId, + targetName = "чужая армия", + soldiers = soldiers, + survivors = soldiers, + departMs = nowMs, + state = MarchState.Outbound, + scout = true, + reconLevel = level, + reconHoldMs = holdMs, + }; + if (!PlanArmyRecon(march, null, out error)) { nextMarchId--; return null; } + march.battleSeed = BattleSeed(march.id, nowMs, "march_" + targetMarchId); + marches.Add(march); + return march; + } + + // Рассчитать поход без старта (для окна: «догонят через N, вернутся через M») + public bool TryPlanArmyRecon(string fromId, int targetMarchId, long nowMs, long holdMs, out long meetMs, out long endMs, out long returnMs, out string error) + { + var probe = new MarchData { fromNodeId = fromId, targetMarchId = targetMarchId, departMs = nowMs, reconHoldMs = holdMs, scout = true, reconLevel = 2 }; + bool ok = PlanArmyRecon(probe, null, out error); + meetMs = probe.arriveMs; endMs = probe.gatherEndMs; returnMs = probe.returnMs; + return ok; + } + + // Перехват, наблюдение, обратный путь -- по составу units (null -- как пехота) + bool PlanArmyRecon(MarchData m, int[] units, out string error) + { + error = null; + MapNode home = GetNode(m.fromNodeId); + MarchData target = FindMarch(m.targetMarchId); + if (home == null || target == null) { error = "Этой армии уже нет на карте"; return false; } + + // 1) Перехват: первый момент, когда отряд успевает дойти до места, где в этот момент будет армия + long limit = target.returnMs > 0 ? target.returnMs : m.departMs + 48L * 3_600_000; + long meet = -1; + int mx = 0, my = 0; + for (long t = m.departMs; t <= limit; t += InterceptStepMs) + { + if (!TryGetPositionAt(target, t, out int x, out int y, out _, out _, out _)) break; // армия ушла с карты + if (m.departMs + TravelTimeMs(home, Point(x, y), units) <= t) { meet = t; mx = x; my = y; break; } + } + if (meet < 0) { error = "Армию не догнать: она уйдёт раньше, чем разведчики до неё доберутся"; return false; } + + // 2) Наблюдение рядом с армией: она должна оставаться на карте до конца + long end = meet + m.reconHoldMs; + if (!TryGetPositionAt(target, end, out int lx, out int ly, out _, out _, out _)) + { + error = "Армия уйдёт с карты раньше, чем закончится наблюдение"; + return false; + } + + // 3) Домой -- из места, где наблюдение кончилось + m.arriveMs = meet; + m.meetX = mx; m.meetY = my; + m.gatherEndMs = end; + m.leaveX = lx; m.leaveY = ly; + m.returnMs = end + TravelTimeMs(Point(lx, ly), home, units); + return true; + } + + // Отряд дошёл до точки перехвата: армия на месте -- наблюдает (время уже рассчитано); армии нет -- домой + void ArriveArmyRecon(MarchData m) + { + MapNode home = GetNode(m.fromNodeId); + int[] units = HasUnits(m) ? m.units : null; + m.victory = false; + m.survivors = m.soldiers; + m.survivorUnits = units != null ? (int[])units.Clone() : null; + m.loot = new System.Collections.Generic.List(); + MarchData target = FindMarch(m.targetMarchId); + m.targetEmpty = target == null || !TryGetPositionAt(target, m.arriveMs, out _, out _, out _, out _, out _); + if (m.targetEmpty) + { + m.gatherEndMs = 0; + m.leaveX = m.meetX; m.leaveY = m.meetY; + m.returnMs = m.arriveMs + TravelTimeMs(Point(m.meetX, m.meetY), home, units); + m.state = MarchState.Returning; + return; + } + m.state = MarchState.Gathering; // наблюдает до gatherEndMs, обратный путь уже рассчитан + } + + // Позиция отряда разведки армии в момент t + bool TryGetArmyReconPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers) + { + x = y = toX = toY = 0; + soldiers = m.soldiers; + MapNode home = GetNode(m.fromNodeId); + if (home == null || t < m.departMs) return false; + if (t < m.arriveMs) + { + LerpXY(home.x, home.y, m.meetX, m.meetY, m.departMs, m.arriveMs, t, out x, out y); + toX = m.meetX; toY = m.meetY; + return true; + } + long backStart = m.targetEmpty || m.gatherEndMs <= 0 ? m.arriveMs : m.gatherEndMs; + if (t < backStart) + { + // идёт рядом с армией; армии уже нет (не должно быть по плану) -- стоит, где кончилось наблюдение + MarchData target = FindMarch(m.targetMarchId); + if (target == null || !TryGetPositionAt(target, t, out x, out y, out toX, out toY, out _)) { x = m.leaveX; y = m.leaveY; toX = x; toY = y; } + return true; + } + if (m.returnMs > 0 && t >= m.returnMs) return false; // дома + LerpXY(m.leaveX, m.leaveY, home.x, home.y, backStart, m.returnMs, t, out x, out y); + toX = home.x; toY = home.y; + return true; + } + + // Отрезок для отрисовки: точки как временные узлы карты (не входят в Nodes) + void GetArmyReconSegment(MarchData m, long nowMs, out MapNode from, out MapNode to, out double progress) + { + TryGetArmyReconPositionAt(m, nowMs, out int x, out int y, out int tx, out int ty, out _); + from = Point(x, y); + to = Point(tx, ty); + progress = 0; + } + + static MapNode Point(int x, int y) => new MapNode { id = "", x = x, y = y, displayName = "" }; + + static void LerpXY(int ax, int ay, int bx, int by, long start, long end, long t, out int x, out int y) + { + if (end <= start) { x = bx; y = by; return; } + long p = Math.Max(0, Math.Min(end - start, t - start)); + x = ax + (int)((long)(bx - ax) * p / (end - start)); + y = ay + (int)((long)(by - ay) * p / (end - start)); + } + } +} diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs.meta b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs.meta new file mode 100644 index 0000000..4243053 --- /dev/null +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.ArmyRecon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a7f5c3d9e1b4a68b4c2d0e8f6a19b37 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs index 11ae9c1..494c10b 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.Patrols.cs @@ -155,6 +155,7 @@ namespace Bogatyri.World public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers) { x = y = toX = toY = soldiers = 0; + if (IsArmyRecon(m)) return TryGetArmyReconPositionAt(m, t, out x, out y, out toX, out toY, out soldiers); MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId); if (home == null || target == null || t < m.departMs) return false; diff --git a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs index 44df21e..af9f008 100644 --- a/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs +++ b/Assets/Scripts/Modules/World/Simulation/WorldSimulation.cs @@ -32,6 +32,9 @@ namespace Bogatyri.World public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки + public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты + public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты) + public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int defenders; // гарнизон цели в момент боя @@ -200,6 +203,7 @@ namespace Bogatyri.World { if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false; m.units = units; + if (IsArmyRecon(m)) { PlanArmyRecon(m, units, out _); return true; } // перехват -- по скорости состава m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units); return true; } @@ -373,7 +377,7 @@ namespace Bogatyri.World // Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома if (nextKind == 0) { - ArriveScout(next); + if (IsArmyRecon(next)) ArriveArmyRecon(next); else ArriveScout(next); events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs }); } else if (nextKind == 1) @@ -544,6 +548,7 @@ namespace Bogatyri.World // Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка. public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress) { + if (IsArmyRecon(m)) { GetArmyReconSegment(m, nowMs, out segmentFrom, out segmentTo, out progress); return; } MapNode home = GetNode(m.fromNodeId); MapNode target = GetNode(m.targetNodeId); diff --git a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs index 1b8eaa9..ef115e8 100644 --- a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs +++ b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs @@ -143,10 +143,14 @@ public class IntelligenceOverlay : MonoBehaviour } // Имя владельца -- по его поселению на карте («npc_3» -> «Велимир») - string OwnerName(string ownerId) + string OwnerName(string ownerId) => OwnerName(world, ownerId); + + // Имя владельца армии -- по его городу на карте (окно чужой армии -- тоже) + public static string OwnerName(WorldSimulation world, string ownerId) { - foreach (var n in world.Nodes) - if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName; + if (world != null) + foreach (var n in world.Nodes) + if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName; return ownerId ?? "?"; } diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index 4127770..4467626 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -85,7 +85,9 @@ public class MarchView : MonoBehaviour void CreateRouteLine(WorldSimulation world, MarchData march) { Vector3 home = WorldMapController.ToWorld(world.GetNode(march.fromNodeId)); - Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId)); + Vector3 target = WorldSimulation.IsArmyRecon(march) + ? new Vector3(march.meetX / 1000f, 0f, march.meetY / 1000f) // разведка армии -- до точки перехвата + : WorldMapController.ToWorld(world.GetNode(march.targetNodeId)); Vector3 delta = target - home; // Тонкая полоса на земле от города до цели @@ -126,7 +128,7 @@ public class MarchView : MonoBehaviour long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") : gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}") - : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; + : WorldSimulation.IsArmyRecon(march) ? "догоняют армию" : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» string time = secondsLeft >= 3600 ? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}" diff --git a/Assets/Scripts/WorldMap/ReconReportText.cs b/Assets/Scripts/WorldMap/ReconReportText.cs index ef83f91..2ad33c3 100644 --- a/Assets/Scripts/WorldMap/ReconReportText.cs +++ b/Assets/Scripts/WorldMap/ReconReportText.cs @@ -49,6 +49,41 @@ public static class ReconReportText return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин"; } + // Окно чужой армии (Разведка М2): что видно сейчас, последний отчёт и «Разведка II / III» -- отряд догонит армию, + // пойдёт рядом время наблюдения и вернётся с отчётом. Воинов -- до ArmyReconSoldiers из свободных + public const int ArmyReconSoldiers = 10; + + public static void OpenArmyWindow(KnownContact army, System.Action hint) + { + var card = UIKit.Modal("ЧУЖАЯ АРМИЯ", out GameObject root); + long now = MarchManager.NowMs; + string owner = army.Identified ? IntelligenceOverlay.OwnerName(MarchManager.Instance.World, army.OwnerId) : "владелец неизвестен"; + UIKit.Text(card, $"~{army.ApproxMin}–{army.ApproxMax} воинов, {owner}", 26, UIKit.Theme.Text, TextAlignmentOptions.Center); + if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(army.ArmyId, out var report)) + UIKit.Text(card, Format(report, now), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Left); + + int free = GameServices.TryGet(out IArmy myArmy) ? myArmy.AvailableForMarch : 0; + int soldiers = Mathf.Min(ArmyReconSoldiers, free); + UIKit.Text(card, soldiers > 0 ? $"Отправить {soldiers} воинов: догонят армию, пойдут рядом и вернутся с отчётом. Без боя." + : "Нет свободных воинов для разведки", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + for (int level = 2; level <= 3; level++) + { + int lv = level; + string name = lv == 3 ? "III" : "II"; + bool ok = MarchManager.Instance.TryPlanArmyRecon(army.ArmyId, lv, out long meet, out long reportAt, out long home, out string error); + string plan = ok ? $"догонят через {Ago(meet - now)}, отчёт через {Ago(reportAt - now)}, дома через {Ago(home - now)}" : error; + UIKit.Text(card, $"Разведка {name}: {plan}", 22, ok ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center); + var b = UIKit.Button(card, $"Разведка {name}", ok && soldiers > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () => + { + bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, out string err); + hint?.Invoke(started ? $"Разведка {name}: {soldiers} воинов идут догонять армию" : "Разведка не начата: " + err); + Object.Destroy(root); + }, -1, 80, 26); + b.interactable = ok && soldiers > 0; + } + UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28); + } + // Окно «Отчёты»: последние отчёты, новые сверху public static void OpenWindow() { diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index 2c93a98..ef7c762 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -510,6 +510,9 @@ public class WorldMapController : MonoBehaviour if (ArmiesWindow.IsOpen) return; if (!mapCamera.TryGetTap(out Vector3 world)) return; + // Видимая чужая армия под пальцем -- окно разведки армии (Разведка М2) + if (FindArmyAt(world, out KnownContact army)) { ReconReportText.OpenArmyWindow(army, s => hintText.text = s); return; } + MapNode node = FindNodeAt(world); if (node == null) return; @@ -521,6 +524,22 @@ public class WorldMapController : MonoBehaviour else if (node.canBeAttacked) OpenAttackPanel(node); } + // Чужая армия в обзоре рядом с точкой нажатия (метка-ромб размером ~0,55 клетки; зона чуть больше) + static bool FindArmyAt(Vector3 world, out KnownContact found) + { + found = default; + if (!GameServices.TryGet(out IIntelligence intel)) return false; + float best = 0.7f; + bool any = false; + foreach (var c in intel.Contacts) + { + if (!c.Observed) continue; + float d = Vector2.Distance(new Vector2(world.x, world.z), new Vector2(c.X / 1000f, c.Y / 1000f)); + if (d <= best) { best = d; found = c; any = true; } + } + return any; + } + MapNode FindNodeAt(Vector3 world) { foreach (var node in World.Nodes) @@ -841,7 +860,7 @@ public class WorldMapController : MonoBehaviour void HandleReconCompleted(MarchData march) { - if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report)) + if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(MarchManager.ReconTargetId(march), out var report)) EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой"); } diff --git a/Docs/Modules/Intelligence.md b/Docs/Modules/Intelligence.md index 8d01646..a8e3fdd 100644 --- a/Docs/Modules/Intelligence.md +++ b/Docs/Modules/Intelligence.md @@ -58,7 +58,7 @@ | Поселение (соседний город) | 2 игровых часа | 4 | | Замок | 3 | 6 | | Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 | -| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | | +| Вражеская армия | 1 | 2 | 1 игровой час = 5 реальных минут. @@ -69,6 +69,8 @@ - События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30. - Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`). +**Разведка армии (06.11):** тап по видимой чужой армии (красный ромб) — окно «ЧУЖАЯ АРМИЯ»: примерная численность, последний отчёт и «Разведка II / III» для 10 свободных воинов с планом «догонят через …, отчёт через …, дома через …». Отряд физически идёт к точке перехвата (где армия будет, когда он дойдёт), наблюдает, идя рядом с ней, отчёт — на конец наблюдения, домой — из места, где наблюдение кончилось. Не догнать или армия уйдёт с карты раньше конца наблюдения — поход не начинается, причина в окне. Армия пропала до перехвата — отряд дошёл до точки и вернулся без отчёта. Цель отчёта — `march_<номер>` (как в `GetArmies`), снимок — `MarchManager.TryGetReconTarget` (`ReconTargetKind.Army`: точный состав, без стен и ресурсов). Время — `IntelligenceRules.ReconArmyGameHours` {1, 2}. Патрули-заглушки идут ~40 минут — их можно догнать, только если они проходят рядом с городом. + **На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему. ## Патрули-заглушки (временно) @@ -132,6 +134,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | М2: разведка движущейся армии — тап по чужой армии, «Разведка II / III» (1 / 2 игровых часа), перехват и сопровождение физическим маршем, отчёт и возвращение; `ReconTargetKind.Army`, `ReconArmyGameHours`. Тесты: время, перехват и сопровождение, отказ, цель пропала. | | 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. | | 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. | | 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 9ca9af2..7b73da9 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -158,6 +158,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | Поход на движущуюся армию (Разведка М2): `MarchData.targetMarchId`, `meetX/meetY`, `leaveX/leaveY`; `WorldSimulation.ArmyRecon.cs` — `StartArmyRecon`, `TryPlanArmyRecon` (перехват с шагом 10 с, наблюдение рядом с армией, путь домой от места конца наблюдения), позиция и отрезок для отрисовки; `MarchManager.TryStartArmyRecon`, `TryPlanArmyRecon`, `ReconTargetId`. Всё время похода известно при выступлении — офлайн считается так же. | | 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. | | 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`. | -- 2.54.0 From a72a8941ffa71a48802325da98246bbab81e2c00 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 6 Nov 2026 16:57:29 +0100 Subject: [PATCH 3/3] =?UTF-8?q?3D:=20=D0=BC=D0=B5=D1=81=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=B7=D0=B0=20=D0=BA=D1=80=D0=B0=D0=B5?= =?UTF-8?q?=D0=BC=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BA=D0=B0=20--=20?= =?UTF-8?q?=D1=85=D0=BE=D0=BB=D0=BC=D1=8B,=20=D1=80=D0=B5=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B3=D0=BE=D1=80=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=BB=D0=B6=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=88=D0=B2=D0=B0=20=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=83=D1=85=D0=BE=D0=B4=D1=8F=D1=82=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BD=D0=B8=D0=BD=D1=83;=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=B9=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=B0=D0=BD?= 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 | 74 ++++++++++++++++++++--- Assets/Scripts/City3D/City3DLayout.cs | 19 +++++- Docs/Modules/Settlement.md | 1 + 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index 87e5da9..8ed3d29 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -408,17 +408,20 @@ public class City3DController : MonoBehaviour, City3DSave.ILive 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); + // Местность за краем участка -- продолжение холмов, реки и гор (City3DLayout.SkirtHeight), дальше -- равнина + BuildSkirt(); + Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.3f, 0.5f, 0.26f), + new Vector3(n / 2f, City3DLayout.SkirtFloor - 0.05f, n / 2f), new Vector2(n + 2 * City3DLayout.SkirtCells + 300f, n + 2 * City3DLayout.SkirtCells + 300f), 0.1f); var forest = new System.Random(7); - for (int i = 0; i < 140; i++) + for (int i = 0; i < 260; 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)); + // лес вокруг участка: гуще у края, реже дальше; не в реке и не на горах + float angle = (float)forest.NextDouble() * Mathf.PI * 2f; + float r = n * 0.6f + Mathf.Pow((float)forest.NextDouble(), 1.6f) * (City3DLayout.SkirtCells + 4f); + float x = n / 2f + Mathf.Cos(angle) * r, z = n / 2f + Mathf.Sin(angle) * r; + if (x > -0.5f && x < n + 0.5f && z > -0.5f && z < n + 0.5f) continue; // внутри участка -- свои деревья + if (City3DLayout.IsRiver(x, z) || City3DLayout.IsMountain(x, z)) continue; + Map3D.Tree(transform, "ForestTree", new Vector3(x, City3DLayout.SkirtHeight(x, z), z), 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6)); } // Вода -- плоскость над руслом @@ -427,10 +430,61 @@ public class City3DController : MonoBehaviour, City3DSave.ILive 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.transform.localScale = new Vector3(9f, 0.05f, n + 2 * (City3DLayout.SkirtCells - 22)); // река течёт и за краем участка water.GetComponent().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f)); } + // Местность вокруг участка: сетка 1 клетка (на стыке -- те же вершины, что у земли участка, без щелей), + // без самого участка. Цвет -- тот же, что у земли (трава пятнами, берег, вода, горы со снегом), 1 пиксель на клетку + void BuildSkirt() + { + int n = City3DLayout.Size, s = City3DLayout.SkirtCells, w = n + 2 * s; + var verts = new Vector3[(w + 1) * (w + 1)]; + var uvs = new Vector2[verts.Length]; + for (int z = 0; z <= w; z++) + for (int x = 0; x <= w; x++) + { + float wx = x - s, wz = z - s; + int i = z * (w + 1) + x; + verts[i] = new Vector3(wx, City3DLayout.SkirtHeight(wx, wz), wz); + uvs[i] = new Vector2(x / (float)w, z / (float)w); + } + var tris = new List(w * w * 6); + for (int z = 0; z < w; z++) + for (int x = 0; x < w; x++) + { + if (x >= s && x < s + n && z >= s && z < s + n) continue; // участок -- своя земля + int i = z * (w + 1) + x; + tris.Add(i); tris.Add(i + w + 1); tris.Add(i + 1); + tris.Add(i + 1); tris.Add(i + w + 1); tris.Add(i + w + 2); + } + var mesh = new Mesh { name = "City3D_Skirt", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 }; + mesh.vertices = verts; + mesh.uv = uvs; + mesh.SetTriangles(tris, 0); + mesh.RecalculateNormals(); + + var tex = new Texture2D(w, w, TextureFormat.RGBA32, false) { filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp }; + var px = new Color32[w * w]; + for (int z = 0; z < w; z++) + for (int x = 0; x < w; x++) + { + float wx = x - s + 0.5f, wz = z - s + 0.5f, h = City3DLayout.SkirtHeight(wx, wz); + Color c = City3DLayout.IsRiver(wx, wz) ? new Color(0.32f, 0.5f, 0.72f) + : City3DLayout.IsMountain(wx, wz) && h > 1.5f ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((h - 3.2f) / 1.5f)) + : Mathf.Abs(wx - City3DLayout.RiverX(wz)) < 2.2f ? 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(wx * 0.25f, wz * 0.25f)); + px[z * w + x] = c; + } + tex.SetPixels32(px); + tex.Apply(false); + + var go = new GameObject("Skirt"); + go.transform.SetParent(transform, false); + go.AddComponent().sharedMesh = mesh; + go.AddComponent().sharedMaterial = Map3D.TexturedMat(tex); // без коллайдера: тап и стройка -- только на участке + } + void BuildObstacles() { int i = 0; diff --git a/Assets/Scripts/City3D/City3DLayout.cs b/Assets/Scripts/City3D/City3DLayout.cs index f5979ac..1ffb485 100644 --- a/Assets/Scripts/City3D/City3DLayout.cs +++ b/Assets/Scripts/City3D/City3DLayout.cs @@ -39,9 +39,22 @@ public sealed class City3DLayout 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 float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f); + public static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f; + public static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f; + + // Местность за краем участка (только вид, стройки нет): те же холмы, река и горы продолжаются без шва, + // к дальнему краю (SkirtCells) плавно опускаются до общей равнины SkirtFloor (06.11: при отдалении край участка + // выглядел обрезанным -- холмы и река обрывались уступом) + public const int SkirtCells = 60; + public const float SkirtFloor = -0.7f; + + public static float SkirtHeight(float x, float z) + { + float outside = Mathf.Max(0f, Mathf.Max(Mathf.Max(-x, x - Size), Mathf.Max(-z, z - Size))); + float fade = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(SkirtCells - 20f, SkirtCells, outside)); + return Mathf.Lerp(SkirtFloor, HeightAt(x, z), fade); + } public static City3DLayout Create(PlacementRules rules = null) { diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 90567c3..5d42225 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -119,6 +119,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 06.11.2026 | Claude (Саша) | 3D: местность за краем участка — 60 клеток вокруг (`City3DController.BuildSkirt`, `City3DLayout.SkirtHeight`): те же холмы, река и горы продолжаются без шва (на стыке те же вершины) и к дальнему краю плавно опускаются до равнины; река течёт дальше, вокруг лес. Только вид: без коллайдера, стройки и тапов нет. Раньше при отдалении участок обрывался уступом. | | 06.11.2026 | Claude (Саша) | Герой у Терема в 3D (перенос из 2D): `City3D/City3DHero.cs` — фигурка из простых форм появляется у ворот достроенного Терема и гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше 6 клеток от Терема, шагами на соседнюю клетку — сквозь здания не проходит; тап — фраза и окно героя; в режиме стройки и переноса прячется. Создаёт и убирает `City3DController.UpdateHero`. На экономику и сохранение не влияет. | | 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. | | 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. | -- 2.54.0