Feature/3d hud #18

Merged
Alexander merged 59 commits from feature/3d-hud into develop 2026-11-06 18:09:33 +03:00
7 changed files with 260 additions and 20 deletions
Showing only changes of commit f78eb7ea7e - Show all commits
+108 -1
View File
@@ -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<City3DCamera, float> Get; public System.Action<City3DCamera, float> 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<City3DCamera>();
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<ContentSizeFitter>().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()
{
+29
View File
@@ -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<Camera>();
LoadDevTuning();
if (cam != null && perspective)
{
cam.orthographic = false;
+76 -18
View File
@@ -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<SettlementModule.PlacedBuildingSaveEntry>())
{
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<ILeveledBuilding>();
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<ILeveledBuilding>();
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<City3DSave.Entry> pendingFrom2D = new List<City3DSave.Entry>();
// Кнопка "Перенести" в окне здания
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, которые ждали места
}
}
+4
View File
@@ -26,6 +26,9 @@ public sealed class City3DSave : ISaveParticipant
public int nextId = 1;
public List<Entry> buildings = new List<Entry>();
public List<string> cleared = new List<string>();
// Здания 2D-города, которым при переносе не нашлось места (зона закрыта, всё занято): ждут и ставятся,
// как только место появится. Не теряются (05.11)
public List<Entry> pendingFrom2D = new List<Entry>();
}
public interface ILive
@@ -53,6 +56,7 @@ public sealed class City3DSave : ISaveParticipant
{
if (Cached.buildings == null) Cached.buildings = new List<Entry>();
if (Cached.cleared == null) Cached.cleared = new List<string>();
if (Cached.pendingFrom2D == null) Cached.pendingFrom2D = new List<Entry>();
}
Live?.OnSaveLoaded();
}
@@ -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<string> 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<string>();
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]);
}
@@ -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()
{
+1
View File
@@ -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`) — не нужно закрывать и открывать окно. |