diff --git a/Assets/Scripts/App/DebugPanel.cs b/Assets/Scripts/App/DebugPanel.cs index 812f4ba..920738f 100644 --- a/Assets/Scripts/App/DebugPanel.cs +++ b/Assets/Scripts/App/DebugPanel.cs @@ -85,11 +85,118 @@ public class DebugPanel : MonoBehaviour Btn(row4, "Рабочие", UIKit.Theme.Blue, () => BuildingWindow.Open(null)); gridButton = UIKit.Button(row4, GridLabel, UIKit.Theme.Blue, ToggleGrid, -1, 70, 24); UIKit.Size(gridButton, -1, 70, 1); - Btn(row4, "Закрыть", UIKit.Theme.Gray, () => panel.SetActive(false)); + Btn(row4, "Камера", UIKit.Theme.Blue, ToggleCameraCard); + Btn(row4, "Закрыть", UIKit.Theme.Gray, () => { panel.SetActive(false); if (cameraCard != null) cameraCard.SetActive(false); }); panel.SetActive(false); } + // ---------------- Камера 3D-поселения (решение 05.11: перспектива, числа подбираем в Unity и на телефоне) ---------------- + + GameObject cameraCard; + TextMeshProUGUI cameraInfo; + + // Ползунок: подпись, значение, диапазон. get/set -- поле камеры + struct CamSlider { public string Label, Format; public float Min, Max; public System.Func Get; public System.Action Set; public string Pref; } + + static readonly CamSlider[] CamSliders = + { + new CamSlider { Label = "Наклон", Format = "0°", Min = 25, Max = 85, Pref = City3DCamera.PrefPitch, + Get = c => c.pitch, Set = (c, v) => c.pitch = v }, + new CamSlider { Label = "Перспектива", Format = "0°", Min = 15, Max = 75, Pref = City3DCamera.PrefFov, + Get = c => c.fieldOfView, Set = (c, v) => c.SetFieldOfView(v) }, + new CamSlider { Label = "Ближе всего", Format = "0", Min = 3, Max = 30, Pref = City3DCamera.PrefMin, + Get = c => c.minDistance, Set = (c, v) => { c.minDistance = Mathf.Min(v, c.maxDistance - 1); c.distance = Mathf.Max(c.distance, c.minDistance); } }, + new CamSlider { Label = "Дальше всего", Format = "0", Min = 20, Max = 120, Pref = City3DCamera.PrefMax, + Get = c => c.maxDistance, Set = (c, v) => { c.maxDistance = Mathf.Max(v, c.minDistance + 1); c.distance = Mathf.Min(c.distance, c.maxDistance); } }, + new CamSlider { Label = "Сейчас", Format = "0", Min = 3, Max = 120, Pref = null, + Get = c => c.distance, Set = (c, v) => c.distance = Mathf.Clamp(v, c.minDistance, c.maxDistance) }, + }; + + static City3DCamera Cam3D => Object.FindAnyObjectByType(); + + void ToggleCameraCard() + { + if (cameraCard != null) { bool show = !cameraCard.activeSelf; if (show) BuildCameraCard(); else cameraCard.SetActive(false); return; } + BuildCameraCard(); + } + + void BuildCameraCard() + { + if (cameraCard != null) Destroy(cameraCard); + var cam = Cam3D; + var safe = panel.transform.parent; + var card = UIKit.Card(safe, new Color(0.08f, 0.08f, 0.11f, 0.95f), 20, 10); + card.anchorMin = card.anchorMax = new Vector2(0, 0.5f); + card.pivot = new Vector2(0, 0.5f); + card.anchoredPosition = new Vector2(135 + 640, 0); + card.sizeDelta = new Vector2(640, 0); + card.gameObject.AddComponent().verticalFit = ContentSizeFitter.FitMode.PreferredSize; + cameraCard = card.gameObject; + + var title = UIKit.Text(card, "КАМЕРА 3D-ПОСЕЛЕНИЯ (ТЕСТ)", 28, UIKit.Theme.Gold, TextAlignmentOptions.Center); + title.fontStyle = FontStyles.Bold; + if (cam == null) + { + UIKit.Text(card, "Откройте 3D-город (сцена City3D)", 24, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + } + else + { + foreach (var def in CamSliders) + { + var d = def; + var row = UIKit.Row(card, 60, 12); + var label = UIKit.Text(row, d.Label, 24); + UIKit.Size(label, 170); + var value = UIKit.Text(row, d.Get(cam).ToString(d.Format), 24, UIKit.Theme.TextDim, TextAlignmentOptions.Right); + UIKit.Slider(row, Mathf.InverseLerp(d.Min, d.Max, d.Get(cam)), t => + { + var c = Cam3D; + if (c == null) return; + d.Set(c, Mathf.Lerp(d.Min, d.Max, t)); + value.text = d.Get(c).ToString(d.Format); + if (d.Pref != null) PlayerPrefs.SetFloat(d.Pref, d.Get(c)); // запомнить на этом устройстве + c.Apply(); + UpdateCameraInfo(); + }); + value.transform.SetAsLastSibling(); + UIKit.Size(value, 80); + } + } + cameraInfo = UIKit.Text(card, "", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center); + UpdateCameraInfo(); + + var row2 = UIKit.Row(card, 64, 10); + Btn(row2, "Сбросить", UIKit.Theme.Gray, ResetCamera); + Btn(row2, "Закрыть", UIKit.Theme.Gray, () => cameraCard.SetActive(false)); + } + + // Строка, которую удобно переписать в отчёт / в сцену + void UpdateCameraInfo() + { + var c = Cam3D; + if (cameraInfo == null) return; + cameraInfo.text = c == null ? "" : + $"pitch {c.pitch:0} • fieldOfView {c.fieldOfView:0} • minDistance {c.minDistance:0} • maxDistance {c.maxDistance:0} • distance {c.distance:0}\n" + + "Подобранное запоминается на этом устройстве. В сцену (для всех) -- вписать числа в Main Camera → City3DCamera"; + } + + void ResetCamera() + { + foreach (var d in CamSliders) if (d.Pref != null) PlayerPrefs.DeleteKey(d.Pref); + var c = Cam3D; + if (c != null) + { + c.pitch = c.DefaultPitch; + c.SetFieldOfView(c.DefaultFov); + c.minDistance = c.DefaultMin; + c.maxDistance = c.DefaultMax; + c.distance = Mathf.Clamp(c.distance, c.minDistance, c.maxDistance); + c.Apply(); + } + BuildCameraCard(); + } + // Отладочная сетка стройки (3D-поселение и 2D-город): видна всегда, ярче, каждая 5-я линия жёлтая (05.11, просьба Миши) void ToggleGrid() { diff --git a/Assets/Scripts/City3D/City3DCamera.cs b/Assets/Scripts/City3D/City3DCamera.cs index 7425fd0..19d32a7 100644 --- a/Assets/Scripts/City3D/City3DCamera.cs +++ b/Assets/Scripts/City3D/City3DCamera.cs @@ -29,9 +29,38 @@ public class City3DCamera : MonoBehaviour public bool perspective = true; public float fieldOfView = 40f; + // Подбор камеры в панели DEV (окно «Камера»): наклон, поле зрения, ближе / дальше всего. Запоминается на этом + // устройстве -- только в редакторе и отладочной сборке; в обычной сборке -- числа из сцены (05.11) + public const string PrefPitch = "dev_cam3d_pitch", PrefFov = "dev_cam3d_fov", PrefMin = "dev_cam3d_min", PrefMax = "dev_cam3d_max"; + + // Числа по умолчанию (из сцены) -- для кнопки «Сбросить» в DEV + public float DefaultPitch { get; private set; } + public float DefaultFov { get; private set; } + public float DefaultMin { get; private set; } + public float DefaultMax { get; private set; } + + public void SetFieldOfView(float fov) + { + fieldOfView = fov; + if (cam != null && !cam.orthographic) cam.fieldOfView = fov; + } + + void LoadDevTuning() + { + DefaultPitch = pitch; DefaultFov = fieldOfView; DefaultMin = minDistance; DefaultMax = maxDistance; +#if UNITY_EDITOR || DEVELOPMENT_BUILD + pitch = PlayerPrefs.GetFloat(PrefPitch, pitch); + fieldOfView = PlayerPrefs.GetFloat(PrefFov, fieldOfView); + minDistance = PlayerPrefs.GetFloat(PrefMin, minDistance); + maxDistance = Mathf.Max(minDistance + 1f, PlayerPrefs.GetFloat(PrefMax, maxDistance)); + distance = Mathf.Clamp(distance, minDistance, maxDistance); +#endif + } + void Awake() { cam = GetComponent(); + LoadDevTuning(); if (cam != null && perspective) { cam.orthographic = false; diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index f483cda..9b62319 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -151,6 +151,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive d.buildings.Add(e); } d.cleared.AddRange(map.Cleared); + d.pendingFrom2D.AddRange(pendingFrom2D); return d; } @@ -169,6 +170,8 @@ public class City3DController : MonoBehaviour, City3DSave.ILive if (d != null) { nextBuildingId = Mathf.Max(1, d.nextId); + pendingFrom2D.Clear(); + if (d.pendingFrom2D != null) pendingFrom2D.AddRange(d.pendingFrom2D); map.RestoreCleared(d.cleared); foreach (var id in d.cleared) if (obstacleViews.TryGetValue(id, out var ov)) { Destroy(ov); obstacleViews.Remove(id); } @@ -195,6 +198,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive PlaceNew(0, c - 2, c - 2, 0); // новый город: Ратуша в центре if (d == null) MoveFrom2D(); // 3D-города в сохранении ещё нет, а 2D есть -- перенести постройки игрока } + else if (pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места UpdateGrass(); RefreshGround(); if (CitySync.Instance != null) CitySync.Instance.SyncNow(); @@ -220,34 +224,87 @@ public class City3DController : MonoBehaviour, City3DSave.ILive BuildTimer.Begin(th.gameObject, BuildTimer.Kind.Upgrade, old.townHallTimerTargetLevel, old.townHallTimerStartMs, old.townHallTimerEndMs); } - int c = City3DLayout.Size / 2, moved = 0, lost = 0; + int unknown = 0; + pendingFrom2D.Clear(); foreach (var e in old.placedBuildings ?? new List()) { string type3d = City3DGameplay.Type3DFor(e.buildingName); - int type = type3d != null ? System.Array.FindIndex(Types, t => t.Name == type3d) : -1; - if (type <= 0) { Debug.LogWarning($"[3D] Перенос 2D-города: здания «{e.buildingName}» в 3D нет -- пропущено"); lost++; continue; } - var t = Types[type]; - if (!FindFreeSpot(t, c - t.W / 2, c - t.H / 2, out int x, out int y, out int rot) || !PlaceNew(type, x, y, rot)) + if (type3d == null || System.Array.FindIndex(Types, t => t.Name == type3d) <= 0) { - Debug.LogWarning($"[3D] Перенос 2D-города: для «{e.buildingName}» нет свободного места -- пропущено"); - lost++; + Debug.LogWarning($"[3D] Перенос 2D-города: здания «{e.buildingName}» в 3D нет -- пропущено"); + unknown++; continue; } - string id = "b_" + (nextBuildingId - 1); - var view = buildingViews[id]; - var leveled = view.GetComponent(); - if (leveled != null) leveled.Level = Mathf.Max(1, e.level); - City3DSave.ReadTimer(new City3DSave.Entry + pendingFrom2D.Add(new City3DSave.Entry { - level = Mathf.Max(1, e.level), timerKind = e.timerKind, timerStartMs = e.timerStartMs, - timerEndMs = e.timerEndMs, timerTargetLevel = e.timerTargetLevel, - }, view); - moved++; + type = type3d, level = Mathf.Max(1, e.level), timerKind = e.timerKind, + timerStartMs = e.timerStartMs, timerEndMs = e.timerEndMs, timerTargetLevel = e.timerTargetLevel, + }); } - Debug.Log($"[3D] Перенос 2D-города: Ратуша {old.townHallLevel} ур., зданий перенесено {moved}" + (lost > 0 ? $", пропущено {lost}" : "")); - if (moved > 0 || lost > 0) Say(lost == 0 ? $"Город перенесён в 3D: {moved} зданий" : $"Город перенесён в 3D: {moved} зданий, {lost} не поместились"); + int total = pendingFrom2D.Count; + int placed = PlacePendingFrom2D(false); + Debug.Log($"[3D] Перенос 2D-города: Ратуша {old.townHallLevel} ур., зданий {total}: поставлено {placed}, ждут места {pendingFrom2D.Count}" + + (unknown > 0 ? $", нет в 3D {unknown}" : "")); + if (total > 0) + Say(pendingFrom2D.Count == 0 ? $"Город перенесён в 3D: {placed} зданий" + : $"Город перенесён в 3D: {placed} зданий, {pendingFrom2D.Count} ждут места -- встанут, когда оно появится"); } + // Здания из 2D, которые ещё не стоят: ближайшее к центру место, сначала свободное, потом -- с расчисткой деревьев + // и валунов (бесплатно, без награды: это не стройка игрока). Нет места -- ждут (сохраняются в settlement3d). + // Зовётся при переносе, при входе в город и когда Ратуша открыла зону. Возвращает, сколько поставлено + int PlacePendingFrom2D(bool announce = true) + { + int placed = 0, c = City3DLayout.Size / 2; + for (int i = 0; i < pendingFrom2D.Count; i++) + { + var e = pendingFrom2D[i]; + int type = System.Array.FindIndex(Types, t => t.Name == e.type); + if (type <= 0) continue; + var t = Types[type]; + int x, y, rot; + bool found = FindFreeSpot(t, c - t.W / 2, c - t.H / 2, out x, out y, out rot) + || FindSpotIfCleared(t, c - t.W / 2, c - t.H / 2, out x, out y, out rot); + if (!found) continue; + foreach (var obstacle in map.ObstaclesUnder(x, y, t.W, t.H, rot)) + if (map.ClearObstacle(obstacle) && obstacleViews.TryGetValue(obstacle, out var ov)) { Destroy(ov); obstacleViews.Remove(obstacle); } + if (!PlaceNew(type, x, y, rot)) continue; + + var view = buildingViews["b_" + (nextBuildingId - 1)]; + var leveled = view.GetComponent(); + if (leveled != null) leveled.Level = e.level; + City3DSave.ReadTimer(e, view); + pendingFrom2D.RemoveAt(i--); + placed++; + } + if (placed > 0) + { + if (CitySync.Instance != null) CitySync.Instance.SyncNow(); + if (announce) Say($"Из старого города поставлено зданий: {placed}" + (pendingFrom2D.Count > 0 ? $", ещё ждут места: {pendingFrom2D.Count}" : "")); + } + return placed; + } + + bool FindSpotIfCleared(BuildingType t, int x0, int y0, out int x, out int y, out int rot) + { + for (int r = 0; r < City3DLayout.Size; r++) + for (int dy = -r; dy <= r; dy++) + for (int dx = -r; dx <= r; dx++) + { + if (Mathf.Max(Mathf.Abs(dx), Mathf.Abs(dy)) != r) continue; + foreach (int tryRot in new[] { 0, 90 }) + if (map.ValidateIfCleared(x0 + dx, y0 + dy, t.W, t.H, tryRot, townHallLevel, null, out _) == PlaceError.None) + { + x = x0 + dx; y = y0 + dy; rot = tryRot; + return true; + } + } + x = x0; y = y0; rot = 0; + return false; + } + + readonly List pendingFrom2D = new List(); + // Кнопка "Перенести" в окне здания void MoveFromWindow(GameObject view) { @@ -925,6 +982,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2; cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2)); Say($"Ратуша {townHallLevel} ур.: открыта новая зона"); + if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места } } diff --git a/Assets/Scripts/City3D/City3DSave.cs b/Assets/Scripts/City3D/City3DSave.cs index 4ee65d2..c61d015 100644 --- a/Assets/Scripts/City3D/City3DSave.cs +++ b/Assets/Scripts/City3D/City3DSave.cs @@ -26,6 +26,9 @@ public sealed class City3DSave : ISaveParticipant public int nextId = 1; public List buildings = new List(); public List cleared = new List(); + // Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся, + // как только место появится. Не теряются (05.11) + public List pendingFrom2D = new List(); } public interface ILive @@ -53,6 +56,7 @@ public sealed class City3DSave : ISaveParticipant { if (Cached.buildings == null) Cached.buildings = new List(); if (Cached.cleared == null) Cached.cleared = new List(); + if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List(); } Live?.OnSaveLoaded(); } diff --git a/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs index 8c4a60e..39fda21 100644 --- a/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs +++ b/Assets/Scripts/Modules/Settlement/Placement/SettlementMap.cs @@ -152,6 +152,29 @@ namespace Bogatyri.Settlement.Placement // Проверить место: прямоугольник sizeW x sizeH с поворотом rotation, левый нижний угол (x, y). // ignoreId -- здание, которое переносят: его прежний след не мешает. foundationMm -- высота фундамента public PlaceError Validate(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm) + => ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, false, out foundationMm); + + // То же, но деревья и валуны не мешают: можно ли поставить здесь, если расчистить (перенос 2D-города, 05.11) + public PlaceError ValidateIfCleared(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, out int foundationMm) + => ValidateCore(x, y, sizeW, sizeH, rotation, townHallLevel, ignoreId, true, out foundationMm); + + // Деревья и валуны (id) под прямоугольником -- что придётся расчистить. Каждый -- один раз + public List ObstaclesUnder(int x, int y, int sizeW, int sizeH, int rotation) + { + rotation = NormalizeRotation(rotation); + int w = rotation == 90 || rotation == 270 ? sizeH : sizeW; + int h = rotation == 90 || rotation == 270 ? sizeW : sizeH; + var ids = new List(); + for (int cy = y; cy < y + h; cy++) + for (int cx = x; cx < x + w; cx++) + { + string id = ObstacleAt(cx, cy); + if (id != null && !ids.Contains(id)) ids.Add(id); + } + return ids; + } + + PlaceError ValidateCore(int x, int y, int sizeW, int sizeH, int rotation, int townHallLevel, string ignoreId, bool ignoreObstacles, out int foundationMm) { foundationMm = 0; rotation = NormalizeRotation(rotation); @@ -167,7 +190,7 @@ namespace Bogatyri.Settlement.Placement if (kind[i] == CellKind.Water) return PlaceError.Water; if (kind[i] == CellKind.Mountain) return PlaceError.Mountain; if (!IsOpen(cx, cy, townHallLevel)) return PlaceError.ZoneLocked; - if (obstacleAt.ContainsKey(i)) return PlaceError.Obstacle; + if (!ignoreObstacles && obstacleAt.ContainsKey(i)) return PlaceError.Obstacle; min = Math.Min(min, heightMm[i]); max = Math.Max(max, heightMm[i]); } diff --git a/Assets/Scripts/Modules/Tests/PlacementTests.cs b/Assets/Scripts/Modules/Tests/PlacementTests.cs index 759bf6f..3ab5efd 100644 --- a/Assets/Scripts/Modules/Tests/PlacementTests.cs +++ b/Assets/Scripts/Modules/Tests/PlacementTests.cs @@ -35,6 +35,24 @@ public class PlacementTests Assert.AreEqual(PlaceError.None, map.Validate(15, 2, 2, 2, 0, 2, null, out _), "11: Ратуша 2 открыла зону"); } + [Test] + public void Placement_IfCleared_IgnoresOnlyTreesAndListsThem() + { + var map = Map(); + Assert.AreEqual(PlaceError.Obstacle, map.Validate(4, 4, 2, 2, 0, 1, null, out _), "дерево мешает"); + Assert.AreEqual(PlaceError.None, map.ValidateIfCleared(4, 4, 2, 2, 0, 1, null, out _), "если расчистить -- можно"); + var under = map.ObstaclesUnder(4, 4, 2, 2, 0); + Assert.AreEqual(1, under.Count, "под зданием -- одно дерево"); + Assert.AreEqual("tree_1", under[0]); + Assert.AreEqual(0, map.ObstaclesUnder(1, 1, 2, 2, 0).Count, "рядом деревьев нет"); + Assert.AreEqual(PlaceError.Water, map.ValidateIfCleared(9, 2, 2, 2, 0, 1, null, out _), "река остаётся рекой"); + Assert.AreEqual(PlaceError.ZoneLocked, map.ValidateIfCleared(15, 2, 2, 2, 0, 1, null, out _), "закрытая зона -- закрыта"); + map.Place("a", "Дом", 1, 1, 2, 2, 0, 1); + Assert.AreEqual(PlaceError.Occupied, map.ValidateIfCleared(1, 1, 2, 2, 0, 1, null, out _), "чужое здание мешает"); + Assert.IsTrue(map.ClearObstacle("tree_1")); + Assert.AreEqual(PlaceError.None, map.Place("b", "Дом", 4, 4, 2, 2, 0, 1), "после расчистки ставится"); + } + [Test] public void Placement_GapBetweenBuildings() { diff --git a/Docs/Modules/Settlement.md b/Docs/Modules/Settlement.md index b10a480..4286932 100644 --- a/Docs/Modules/Settlement.md +++ b/Docs/Modules/Settlement.md @@ -119,6 +119,7 @@ | Дата | Кто | Что | | --- | --- | --- | +| 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` по расстоянию (как видно перспективной камерой с этого расстояния). | | 05.11.2026 | Claude (Саша) | 3D: дымка идёт за камерой (`City3DCamera.Apply`: начало и конец — от расстояния камеры), поэтому при отдалении город больше не уходит в серую дымку. Окно здания: кнопка «Улучшить» (Ратуша и уровни зданий) сама становится зелёной, когда ресурсов стало хватать (`BuildingWindow.LiveAfford`) — не нужно закрывать и открывать окно. |