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 → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. |