From de105c4cbec6290603a12904577d06852ebab6ec Mon Sep 17 00:00:00 2001 From: Valdemar Date: Fri, 6 Nov 2026 13:17:56 +0300 Subject: [PATCH] =?UTF-8?q?3D:=20=D0=BA=D0=B0=D0=BC=D0=B5=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=B0=D1=85=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D1=85=20=D0=B7=D0=BE?= =?UTF-8?q?=D0=BD,=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B2=D0=B8=D0=B4=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=81=D1=8F=20=D0=B7=D0=BE=D0=BD=D0=B0,=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=B7=D1=83=D0=BC,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D1=82=D0=B0=D0=BF=D0=B0=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D1=89=D0=B8=D0=BF=D0=BA=D0=B0;=20MVP1=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B2=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D1=83;=20=D0=BD=D0=B0=D0=B2=D1=8B=D0=BA=D0=B8=20=D0=B3=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=B2=D0=BE=D0=BB=D0=B0,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BD=D0=B5=D1=82=20=D0=B2=20=D1=88=D1=80?= =?UTF-8?q?=D0=B8=D1=84=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Editor/SceneListFixer.cs | 5 +- Assets/Scripts/App/BuildingWindow.cs | 2 +- Assets/Scripts/City3D/City3DCamera.cs | 142 +++++++++++++++++++--- Assets/Scripts/City3D/City3DController.cs | 18 ++- Docs/Modules/Settlement.md | 1 + 5 files changed, 145 insertions(+), 23 deletions(-) diff --git a/Assets/Editor/SceneListFixer.cs b/Assets/Editor/SceneListFixer.cs index dc353c8..10fc70d 100644 --- a/Assets/Editor/SceneListFixer.cs +++ b/Assets/Editor/SceneListFixer.cs @@ -4,6 +4,7 @@ using UnityEditor; // Следит, чтобы сцена карты мира (WorldMap, от Саши) была в Build Settings -- // иначе переход город -> карта не работает. Срабатывает сам после каждой компиляции. // Стартовая сцена (05.11): 3D-поселение City3D, если оно есть в проекте, иначе 2D-город MVP1. +// Выключенный в Build Settings MVP1 (06.11) скрипт не трогает и заново не добавляет. [InitializeOnLoad] public static class SceneListFixer { @@ -18,7 +19,9 @@ public static class SceneListFixer var scenes = new List(EditorBuildSettings.scenes); bool changed = false; string start = AssetDatabase.LoadAssetAtPath(City3DScene) != null ? City3DScene : CityScene; - foreach (var path in new[] { start, CityScene, WorldScene }) + // 2D-город (MVP1) больше не развиваем (06.11): при 3D-поселении его в список не добавляем и не включаем + var wanted = new[] { start, WorldScene }; + foreach (var path in wanted) { if (AssetDatabase.LoadAssetAtPath(path) == null) continue; if (scenes.Exists(s => s.path == path)) continue; diff --git a/Assets/Scripts/App/BuildingWindow.cs b/Assets/Scripts/App/BuildingWindow.cs index 7d9e9b1..77f3930 100644 --- a/Assets/Scripts/App/BuildingWindow.cs +++ b/Assets/Scripts/App/BuildingWindow.cs @@ -452,7 +452,7 @@ public static class BuildingWindow for (int i = 0; i < GameData.HeroSkillNames.Length; i++) { bool has = gp.HasHeroSkill(i); - UIKit.Text(card, has ? $"✓ {GameData.HeroSkillNames[i]}" + UIKit.Text(card, has ? $"• {GameData.HeroSkillNames[i]} — открыт" // без «✓»: его нет в шрифте LiberationSans (06.11) : $"{GameData.HeroSkillNames[i]} — с {GameData.HeroSkillLevels[i]} ур.", 22, has ? UIKit.Theme.Text : UIKit.Theme.TextDim); } diff --git a/Assets/Scripts/City3D/City3DCamera.cs b/Assets/Scripts/City3D/City3DCamera.cs index 19d32a7..fa56c53 100644 --- a/Assets/Scripts/City3D/City3DCamera.cs +++ b/Assets/Scripts/City3D/City3DCamera.cs @@ -14,6 +14,25 @@ public class City3DCamera : MonoBehaviour public Vector3 target; // точка на земле, на которую смотрит камера public Rect bounds = new Rect(0, 0, 48, 48); // где может быть target (x, z) + // Открытая часть поселения (зоны по уровню Ратуши), 06.11: центр кадра из неё не уходит, а при самом дальнем + // отдалении она видна целиком -- даже если "Дальше всего" (maxDistance) подобрано меньше. Ширина 0 -- не задана + Rect openArea; + float fitOpenDistance; // с какого расстояния открытая часть видна целиком (между HUD сверху и снизу) + public const float HudTopShare = 0.16f, HudBottomShare = 0.16f; // какую долю экрана закрывает HUD (ТЕСТ) + + // Дальше всего сейчас: подобранное число, но не ближе, чем нужно, чтобы увидеть всё открытое поселение + public float MaxDistanceNow => Mathf.Max(maxDistance, fitOpenDistance); + + public void SetOpenArea(Rect area) + { + openArea = area; + fitOpenDistance = FitDistance(area); + Clamp(); + } + + // Плавное приближение кнопками "+ / −" и колесом: камера доезжает до нужного расстояния за доли секунды + float zoomTo = -1f; + Camera cam; Vector3 focusTarget; float focusDistance; @@ -71,14 +90,60 @@ public class City3DCamera : MonoBehaviour 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); + // отойти так, чтобы область поместилась между HUD сверху и снизу + focusDistance = Mathf.Clamp(FitDistance(area), minDistance, MaxDistanceNow); focusing = true; + zoomTo = -1f; + } + + // Плавно подъехать к точке, не меняя приближения + public void MoveTo(Vector3 point) + { + focusTarget = new Vector3(point.x, 0, point.z); + focusDistance = distance; + focusing = true; + zoomTo = -1f; + } + + // Расстояние, с которого прямоугольник земли (x, z) виден целиком, если смотреть на его центр. Считается по + // настоящей проекции камеры (наклон, поле зрения, форма экрана), поэтому верно при любых числах из DEV + public float FitDistance(Rect area) + { + if (cam == null) cam = GetComponent(); + if (cam == null || area.width <= 0 || area.height <= 0) return 0f; + Vector3 keepPos = transform.position; + Quaternion keepRot = transform.rotation; + float keepOrtho = cam.orthographicSize; + var center = new Vector3(area.center.x, 0, area.center.y); + Vector3[] corners = + { + new Vector3(area.xMin, 0, area.yMin), new Vector3(area.xMax, 0, area.yMin), + new Vector3(area.xMin, 0, area.yMax), new Vector3(area.xMax, 0, area.yMax), + }; + float lo = 1f, hi = 400f; + for (int i = 0; i < 24; i++) // двоичный поиск: ближе -- не помещается, дальше -- помещается + { + float d = (lo + hi) * 0.5f; + PlaceAt(center, d); + bool fits = true; + foreach (var c in corners) + { + Vector3 v = cam.WorldToViewportPoint(c); + if (v.z <= 0 || v.x < 0.02f || v.x > 0.98f || v.y < HudBottomShare || v.y > 1f - HudTopShare) { fits = false; break; } + } + if (fits) hi = d; else lo = d; + } + transform.SetPositionAndRotation(keepPos, keepRot); + cam.orthographicSize = keepOrtho; + return hi; } // Начать / продолжить / закончить перемещение камеры. Зовёт контроллер ввода (он решает, что под пальцем) - public void BeginPan(Vector3 screen) { panning = AllowPan; lastPointer = screen; focusing = false; } + public void BeginPan(Vector3 screen) + { + panning = AllowPan; lastPointer = screen; focusing = false; zoomTo = -1f; + panPlaneY = GroundHeightUnder(screen); + } public void EndPan() { panning = false; } // Палец ещё не сдвинулся настолько, чтобы считать это перетаскиванием: камера стоит, но точка запоминается -- @@ -95,14 +160,25 @@ public class City3DCamera : MonoBehaviour Apply(); // сразу: иначе следующий шаг считался бы от старого положения камеры -- камера дёргалась (05.11) } + // Кнопки "+ / −" и колесо: плавно (06.11). delta > 0 -- ближе public void Zoom(float delta) { focusing = false; - distance = Mathf.Clamp(distance * (1f - delta), minDistance, maxDistance); + float from = zoomTo > 0 ? zoomTo : distance; + zoomTo = Mathf.Clamp(from * (1f - delta), minDistance, MaxDistanceNow); } + float fitPitch, fitFov, fitAspect; // при каких наклоне, поле зрения и форме экрана считали fitOpenDistance + void Update() { + // Наклон или поле зрения поменяли в DEV, повернули экран -- пересчитать, откуда видно всё поселение + if (openArea.width > 0 && cam != null && (fitPitch != pitch || fitFov != cam.fieldOfView || fitAspect != cam.aspect)) + { + fitPitch = pitch; fitFov = cam.fieldOfView; fitAspect = cam.aspect; + fitOpenDistance = FitDistance(openArea); + } + // Колесо мыши (редактор, ПК) float wheel = Input.mouseScrollDelta.y; if (City3DInput.OverHud(Input.mousePosition)) wheel = 0f; // колесо над окнами HUD -- не приближать камеру (05.11) @@ -118,10 +194,18 @@ public class City3DCamera : MonoBehaviour if (previous > 0.01f && current > 0.01f) { focusing = false; - distance = Mathf.Clamp(distance * previous / current, minDistance, maxDistance); + zoomTo = -1f; + distance = Mathf.Clamp(distance * previous / current, minDistance, MaxDistanceNow); } } + if (zoomTo > 0) + { + distance = Mathf.Lerp(distance, zoomTo, 1f - Mathf.Exp(-Time.unscaledDeltaTime * 12f)); + if (Mathf.Abs(distance - zoomTo) < 0.02f) { distance = zoomTo; zoomTo = -1f; } + } + distance = Mathf.Clamp(distance, minDistance, MaxDistanceNow); + if (focusing) { target = Vector3.Lerp(target, focusTarget, Time.unscaledDeltaTime * 2.5f); @@ -133,8 +217,12 @@ public class City3DCamera : MonoBehaviour void Clamp() { - target.x = Mathf.Clamp(target.x, bounds.xMin, bounds.xMax); - target.z = Mathf.Clamp(target.z, bounds.yMin, bounds.yMax); + Rect r = bounds; + if (openArea.width > 0 && openArea.height > 0) // центр кадра -- только над открытой частью поселения + r = Rect.MinMaxRect(Mathf.Max(r.xMin, openArea.xMin), Mathf.Max(r.yMin, openArea.yMin), + Mathf.Min(r.xMax, openArea.xMax), Mathf.Min(r.yMax, openArea.yMax)); + target.x = Mathf.Clamp(target.x, r.xMin, r.xMax); + target.z = Mathf.Clamp(target.z, r.yMin, r.yMax); target.y = 0; } @@ -144,15 +232,8 @@ public class City3DCamera : MonoBehaviour public void Apply() { - Quaternion rot = Quaternion.Euler(pitch, yaw, 0); - transform.rotation = rot; - transform.position = target - rot * Vector3.forward * distance; - // Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города - // на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же - // земли, сколько видно перспективной камерой с этого расстояния if (cam == null) cam = GetComponent(); - if (cam != null && cam.orthographic) - cam.orthographicSize = distance * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad); + PlaceAt(target, distance); if (RenderSettings.fog && RenderSettings.fogMode == FogMode.Linear) { RenderSettings.fogStartDistance = distance + fogStartBehind; @@ -160,11 +241,36 @@ public class City3DCamera : MonoBehaviour } } - // Точка на плоскости земли (y = 0) под экранной точкой + // Поставить камеру: смотрит на точку center с расстояния d. + // Камера сцены может быть ортографической (вид 2.5D без перспективы): у неё расстояние не меняет размер города + // на экране -- «+ / −» и щипок ничего не делали (05.11). Тогда приближение -- через размер кадра: столько же + // земли, сколько видно перспективной камерой с этого расстояния + void PlaceAt(Vector3 center, float d) + { + Quaternion rot = Quaternion.Euler(pitch, yaw, 0); + transform.rotation = rot; + transform.position = center - rot * Vector3.forward * d; + if (cam != null && cam.orthographic) + cam.orthographicSize = d * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad); + } + + // Точка под пальцем на горизонтальной плоскости на высоте земли, где палец коснулся (06.11): на холме карта + // идёт за пальцем точно, а не чуть быстрее или медленнее, как было с плоскостью y = 0 + float panPlaneY; + Vector3 GroundPoint(Vector3 screen) { Ray ray = cam.ScreenPointToRay(screen); - var plane = new Plane(Vector3.up, Vector3.zero); + var plane = new Plane(Vector3.up, new Vector3(0, panPlaneY, 0)); return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target; } + + // Высота земли (объект "Ground" 3D-поселения) под экранной точкой; не попали -- 0 + float GroundHeightUnder(Vector3 screen) + { + if (cam == null) return 0f; + foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(screen), 500f)) + if (hit.collider.gameObject.name == "Ground") return hit.point.y; + return 0f; + } } diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index 9b62319..c95ad13 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -199,6 +199,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive if (d == null) MoveFrom2D(); // 3D-города в сохранении ещё нет, а 2D есть -- перенести постройки игрока } else if (pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места + UpdateCameraArea(); UpdateGrass(); RefreshGround(); if (CitySync.Instance != null) CitySync.Instance.SyncNow(); @@ -349,6 +350,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive 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); + UpdateCameraArea(); cameraRig.Apply(); Map3D.SetUpLook(cam, 30f, 85f); // солнце, небо, дымка вдали @@ -696,8 +698,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive { ghostX = fx; ghostY = fy; ghostRot = frot; // камера плавно подъезжает к месту, не меняя приближения - float size = cameraRig.distance / 1.15f; - cameraRig.FocusOn(new Rect(fx + t.W / 2f - size / 2f, fy + t.H / 2f - size / 2f, size, size)); + cameraRig.MoveTo(new Vector3(fx + t.W / 2f, 0, fy + t.H / 2f)); } EnsureFootprint(); RefreshGround(); @@ -830,7 +831,9 @@ public class City3DController : MonoBehaviour, City3DSave.ILive Vector3 pos; if (Input.touchCount > 0) { - if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; return; } // два пальца -- зум камеры + // два пальца -- зум камеры. Касание считается перетаскиванием: когда пальцы отпустят, это не тап + // (иначе после щипка открывалось окно здания под оставшимся пальцем) (06.11) + if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; pointerMoved = true; return; } Touch touch = Input.GetTouch(0); pos = touch.position; down = touch.phase == TouchPhase.Began; @@ -974,6 +977,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive int before = townHallLevel; townHallLevel = Mathf.Clamp(level, 1, 5); if (townHallLevel == before) return; + UpdateCameraArea(); RefreshGround(); if (ghost != null) UpdateGhost(); if (townHallLevel > before) @@ -986,6 +990,14 @@ public class City3DController : MonoBehaviour, City3DSave.ILive } } + // Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (06.11) + void UpdateCameraArea() + { + if (cameraRig == null) return; + int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2; + cameraRig.SetOpenArea(new Rect(c - half, c - half, half * 2, half * 2)); + } + void Say(string text) { message = text; diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index 4286932..a0d0d95 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -119,6 +119,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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 → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. | | 05.11.2026 | Claude (Саша) | **Решение команды: только 3D-поселение, камера перспективная.** (1) В 3D перенесены Золотой рудник (золото, Ратуша 2, 50 камня), Кристалл (Ратуша 6, цена из `Data_Kristall`) и Терем (`HeroCastle`, окно героя; фигурка героя гуляет только по 2D-сетке, в 3D — нет). (2) Перенос 2D-города: если в сохранении нет `settlement3d`, а есть 2D-город, при первом входе в 3D Ратуша получает уровень и улучшение из 2D, остальные здания встают на ближайшие свободные места с теми же уровнями и стройками (`City3DController.MoveFrom2D`, итог — в Console «[3D] Перенос 2D-города»). Секция `settlement` в файле не меняется. (3) `SceneNavigator.CitySceneName` = City3D; MVP1 выключена в Build Settings (сцена и код 2D остаются в репозитории до конца проверки). (4) `City3DCamera.perspective` (по умолчанию да, поле зрения 40°) — угол, высота и поле зрения ТЕСТ. Меню стройки — в два столбца. | | 05.11.2026 | Claude (Саша) | 3D: камера сцены City3D ортографическая — у неё расстояние не меняет размер города, поэтому «+ / −» и щипок не приближали. `City3DCamera.Apply` для ортографической камеры ставит `orthographicSize` по расстоянию (как видно перспективной камерой с этого расстояния). |