Files
MVP1/Assets/Scripts/City3D/City3DController.cs
T
AlexanderandClaude Opus 5.5 157d3ba510 Кристальная шахта: 3 синих кристалла за 7 реальных суток на шахту, до двух, пауза при полном хранилище (ответ 06.11, п. 3)
- CrystalMine (без Unity) + 5 тестов: ровно 3 за 7 суток, ×12 не действует, 49/50 -> 1, пауза, перезагрузка и часы назад без двойного начисления, офлайн-стройка
- Работает с конца стройки, офлайн досчитывается; вне недельного PvE-предела
- Вторая шахта -- Ратуша 9 (ТЕСТ); окно здания: следующий кристалл / пауза
- DEV «Ресурсы 100 000» -- обычные ресурсы мимо лимита склада

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:31:47 +01:00

1209 lines
64 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using Bogatyri.Settlement.Placement;
using TMPro;
using UnityEngine;
// 3D-ПОСЕЛЕНИЕ, ЭТАП I -- ПРОТОТИП НА ПРИМИТИВАХ (ТЗ «Система строительства поселения» от 04.11.2026).
// Сцена Assets/Scenes/City3D.unity (создаётся: Tools -> Bogatyri -> 3D-город: создать сцену City3D).
//
// Что умеет:
// - местность с холмами, рекой, скалами, деревьями, валунами и травой (City3DLayout);
// - невидимая сетка: видна только в режиме стройки / редактирования вместе с границей открытой зоны;
// - новое здание: выбрать тип -> призрак-куб тянется пальцем, привязан к клеткам, зелёный / красный с причиной,
// поворот 0/90/180/270, «Поставить» / «Отмена»;
// - перенос: кнопка в меню здания или общий режим редактирования; то же здание (тот же id), подтверждение / отмена;
// - расчистка дерева / валуна (без ожидания и цены); трава прячется под зданием и возвращается;
// - зоны по уровню Ратуши (кнопки «Ратуша -/+» -- ТОЛЬКО ДЛЯ ТЕСТА), камера плавно показывает новую зону.
// Правила места -- в модуле Поселения (Bogatyri.Settlement.Placement, тесты PlacementTests), здесь только показ и ввод.
//
// Общий HUD (05.11, Валдемар): в сцене стоит префаб HUD_Canvas -- тот же, что в 2D-городе. Нажатия по HUD
// в сцену не проходят; кнопка "СТРОЙКА" HUD открывает список зданий (BuildModeController.ExternalToggle);
// временные кнопки этой сцены сдвинуты, чтобы не налезать на HUD.
//
// Здания игры (05.11, Валдемар, City3DGameplay): у каждого 3D-здания те же компоненты, что у 2D-префабов --
// тап открывает окно здания (рабочие, добыча, улучшение по таймеру), стройка -- с ценой, лимитом, строителями и
// таймером (BuildTimer), Ратуша -- настоящая (TownHall, её уровень открывает зоны). Город сохраняется (City3DSave).
//
// Пока НЕ подключено (этапы II-III): цена и таймер стройки, Экономика и награда за расчистку, сохранение,
// дороги, настоящие модели. Кнопки -- временные (IMGUI); окна согласуются с Володей.
public class City3DController : MonoBehaviour, City3DSave.ILive
{
// Тип здания для прототипа: максимальный габарит (клеток) с первого уровня, высота куба, цвет
struct BuildingType
{
public string Name;
public int W, H;
public float Height;
public Color Color;
}
static readonly BuildingType[] Types =
{
new BuildingType { Name = "Ратуша", W = 4, H = 4, Height = 3.0f, Color = new Color(0.85f, 0.75f, 0.35f) },
new BuildingType { Name = "Дом", W = 2, H = 2, Height = 1.4f, Color = new Color(0.85f, 0.55f, 0.35f) },
new BuildingType { Name = "Ферма", W = 3, H = 3, Height = 1.0f, Color = new Color(0.9f, 0.85f, 0.4f) },
new BuildingType { Name = "Лесопилка", W = 3, H = 2, Height = 1.5f, Color = new Color(0.55f, 0.4f, 0.25f) },
new BuildingType { Name = "Каменоломня", W = 3, H = 2, Height = 1.3f, Color = new Color(0.6f, 0.6f, 0.62f) },
new BuildingType { Name = "Шахта", W = 2, H = 2, Height = 1.5f, Color = new Color(0.4f, 0.45f, 0.55f) },
new BuildingType { Name = "Склад", W = 2, H = 3, Height = 1.8f, Color = new Color(0.65f, 0.5f, 0.3f) },
new BuildingType { Name = "Амбар", W = 2, H = 3, Height = 1.8f, Color = new Color(0.8f, 0.65f, 0.3f) },
new BuildingType { Name = "Казармы", W = 3, H = 3, Height = 2.0f, Color = new Color(0.75f, 0.3f, 0.3f) },
new BuildingType { Name = "Госпиталь", W = 3, H = 3, Height = 1.8f, Color = new Color(0.85f, 0.95f, 1f) },
// Из 2D-города (решение 05.11): золото, кристаллы, дом героя. Новые типы -- только в конец: сохранение хранит имя
new BuildingType { Name = "Золотой рудник", W = 2, H = 2, Height = 1.5f, Color = new Color(0.95f, 0.8f, 0.3f) },
new BuildingType { Name = "Кристалл", W = 2, H = 2, Height = 1.2f, Color = new Color(0.5f, 0.8f, 0.95f) },
new BuildingType { Name = "Терем", W = 3, H = 3, Height = 2.4f, Color = new Color(0.9f, 0.75f, 0.45f) },
};
enum Mode { View, Edit, Place, Move }
City3DLayout layout;
SettlementMap map;
City3DCamera cameraRig;
Camera cam;
Material baseMaterial;
Texture2D groundTexture;
const int PixelsPerCell = 8;
readonly Dictionary<string, GameObject> buildingViews = new Dictionary<string, GameObject>();
readonly Dictionary<string, int> buildingType = new Dictionary<string, int>();
readonly Dictionary<string, GameObject> obstacleViews = new Dictionary<string, GameObject>();
readonly List<GameObject> grassViews = new List<GameObject>();
int nextBuildingId = 1;
int townHallLevel = 1;
Mode mode = Mode.View;
int placingType = -1; // Place: какой тип ставим
string movingId; // Move: какое здание переносим
int ghostX, ghostY, ghostRot;
PlacedRect moveOriginal; // Move: последнее подтверждённое положение
GameObject ghost, footprint;
Material okMaterial, badMaterial;
PlaceError ghostError;
int ghostFoundation;
bool draggingGhost;
Vector3 pointerDown;
bool pointerMoved, pointerOverGui;
string selectedBuilding, selectedObstacle; // меню здания / препятствия (тап в обычном режиме)
string message = "";
float messageUntil;
// ---------------- Построение сцены ----------------
bool buildMenuOpen; // список "что построить" открывается кнопкой "СТРОЙКА" общего HUD
// HUD закрывает верх (ресурсы) и низ (кнопки) экрана -- временные кнопки сцены ставим между ними (в единицах 720)
const float HudTop = 150f, HudBottom = 150f, HudLeft = 160f;
void ToggleBuildMenu()
{
if (mode == Mode.Place || mode == Mode.Move) { CancelGhost(); buildMenuOpen = false; return; }
buildMenuOpen = !buildMenuOpen;
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
}
// Отладочная сетка (05.11, просьба Миши): видна всегда, ярче, каждая 5-я линия жёлтая -- примерять референсы.
// Включается кнопкой "Сетка" в панели DEV.
public static bool DebugGrid { get; private set; }
public static void SetDebugGrid(bool on)
{
DebugGrid = on;
var city = FindAnyObjectByType<City3DController>();
if (city != null && city.map != null) city.RefreshGround();
}
bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start)
public bool StateApplied => stateApplied;
void OnEnable()
{
BuildModeController.ExternalToggle = ToggleBuildMenu;
BuildModeController.ExternalMove = MoveFromWindow;
CitySync.ExternalCity = true;
City3DSave.Live = this;
EventBus.Subscribe<TownHallLevelChangedEvent>(OnTownHallLevel);
}
void OnDisable()
{
if (BuildModeController.ExternalToggle == (System.Action)ToggleBuildMenu) BuildModeController.ExternalToggle = null;
if (BuildModeController.ExternalMove == (System.Action<GameObject>)MoveFromWindow) BuildModeController.ExternalMove = null;
CitySync.ExternalCity = false;
// Сохраняемся до ухода из сцены (SceneNavigator, выход из игры) -- здесь здания могут быть уже убраны
if (City3DSave.Live == (City3DSave.ILive)this) City3DSave.Live = null;
EventBus.Unsubscribe<TownHallLevelChangedEvent>(OnTownHallLevel);
}
// ---------------- Сохранение (City3DSave) ----------------
public City3DSave.Data CaptureState()
{
// Сохранённый город ещё не поставлен (сохранение в первом кадре -- например, поход вернулся офлайн):
// отдаём то, что было загружено, иначе пустая сцена затёрла бы весь 3D-город
if (!stateApplied || map == null) return City3DSave.Cached;
var d = new City3DSave.Data { nextId = nextBuildingId };
foreach (var b in map.Buildings)
{
if (!buildingViews.TryGetValue(b.Id, out var view) || view == null) continue;
var e = new City3DSave.Entry { id = b.Id, type = b.Type, x = b.X, y = b.Y, rot = b.Rotation, level = City3DGameplay.LevelOf(view) };
City3DSave.WriteTimer(e, view);
d.buildings.Add(e);
}
d.cleared.AddRange(map.Cleared);
d.pendingFrom2D.AddRange(pendingFrom2D);
return d;
}
// Загрузили другое сохранение или сбросили -- город уже стоит, перестраиваем сцену целиком
public void OnSaveLoaded()
{
if (stateApplied) UnityEngine.SceneManagement.SceneManager.LoadScene(gameObject.scene.name);
}
void ApplySaved()
{
stateApplied = true;
var d = City3DSave.Cached;
int c = City3DLayout.Size / 2;
bool hasTownHall = false;
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); }
// Ратуша первой: её уровень открывает зоны
var list = new List<City3DSave.Entry>(d.buildings);
list.Sort((a, b) => (b.type == "Ратуша").CompareTo(a.type == "Ратуша"));
foreach (var e in list)
{
int type = System.Array.FindIndex(Types, t => t.Name == e.type);
if (type < 0) continue;
var t = Types[type];
var rect = new PlacedRect { Id = e.id, Type = t.Name, X = e.x, Y = e.y, SizeW = t.W, SizeH = t.H, Rotation = e.rot };
if (!map.Restore(rect)) continue;
buildingType[e.id] = type;
buildingViews[e.id] = CreateBuildingView(e.id, type);
City3DGameplay.Attach(buildingViews[e.id], t.Name, e.level);
City3DSave.ReadTimer(e, buildingViews[e.id]);
UpdateBuildingView(e.id);
if (type == 0) { hasTownHall = true; townHallLevel = Mathf.Clamp(e.level, 1, 5); }
}
}
if (!hasTownHall)
{
PlaceNew(0, c - 2, c - 2, 0); // новый город: Ратуша в центре
if (d == null) MoveFrom2D(); // 3D-города в сохранении ещё нет, а 2D есть -- перенести постройки игрока
}
else if (pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
UpdateCameraArea();
UpdateGrass();
RefreshGround();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
}
// ---------------- Перенос 2D-города (решение 05.11: 2D-город больше не развиваем) ----------------
// Один раз, когда в сохранении нет 3D-города: Ратуша получает уровень (и улучшение, которое идёт) из 2D,
// остальные здания встают на ближайшие свободные места от центра с теми же уровнем и стройкой.
// 2D-данные в файле не меняются. Здание, которому не нашлось места или типа, пишется в Console
void MoveFrom2D()
{
var old = SettlementModule.Saved2D;
if (old == null || (old.townHallLevel <= 1 && (old.placedBuildings == null || old.placedBuildings.Count == 0))) return;
string hallId = null;
foreach (var pair in buildingType) if (pair.Value == 0) hallId = pair.Key;
var th = hallId != null ? buildingViews[hallId].GetComponent<TownHall>() : null;
if (th != null)
{
th.currentLevel = Mathf.Clamp(old.townHallLevel, 1, th.maxLevel);
ApplyTownHallLevel(th.currentLevel); // зоны -- до расстановки, чтобы здания поместились
if (old.townHallTimerEndMs > 0 && old.townHallTimerTargetLevel > th.currentLevel)
BuildTimer.Begin(th.gameObject, BuildTimer.Kind.Upgrade, old.townHallTimerTargetLevel, old.townHallTimerStartMs, old.townHallTimerEndMs);
}
int unknown = 0;
pendingFrom2D.Clear();
foreach (var e in old.placedBuildings ?? new List<SettlementModule.PlacedBuildingSaveEntry>())
{
string type3d = City3DGameplay.Type3DFor(e.buildingName);
if (type3d == null || System.Array.FindIndex(Types, t => t.Name == type3d) <= 0)
{
Debug.LogWarning($"[3D] Перенос 2D-города: здания «{e.buildingName}» в 3D нет -- пропущено");
unknown++;
continue;
}
pendingFrom2D.Add(new City3DSave.Entry
{
type = type3d, level = Mathf.Max(1, e.level), timerKind = e.timerKind,
timerStartMs = e.timerStartMs, timerEndMs = e.timerEndMs, timerTargetLevel = e.timerTargetLevel,
});
}
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)
{
foreach (var pair in buildingViews)
if (pair.Value == view)
{
if (mode == Mode.Place || mode == Mode.Move) CancelGhost();
buildMenuOpen = false;
StartMoving(pair.Key);
return;
}
}
void OnTownHallLevel(TownHallLevelChangedEvent e) => ApplyTownHallLevel(e.Level);
// Сцена 3D-города открыта (для окон HUD: «Построить Казармы» ставит призрак здесь, а не в 2D-режиме стройки)
public static City3DController Instance { get; private set; }
void OnDestroy()
{
if (Instance == this) Instance = null;
}
// Начать ставить здание по имени 3D-типа ("Казармы"). false -- такого типа нет
public bool PlaceBuilding(string type3d)
{
int type = System.Array.FindIndex(Types, t => t.Name == type3d);
if (type <= 0) return false;
buildMenuOpen = false;
if (UIPanelManager.Instance != null) UIPanelManager.Instance.CloseAll();
string error = City3DGameplay.CanBuild(type3d);
if (error != null) Say(error);
StartPlacing(type);
return true;
}
void Start()
{
Instance = this;
cam = Camera.main;
Map3D.UseRenderer3D(cam); // обычный 3D-рендерер URP (на случай, если сцену собрали вручную)
cameraRig = cam.GetComponent<City3DCamera>() ?? cam.gameObject.AddComponent<City3DCamera>();
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); // солнце, небо, дымка вдали
City3DGameplay.PutHudInFront(cam); // общий HUD -- поверх холмов и деревьев
layout = City3DLayout.Create();
map = layout.Map;
BuildGround();
BuildObstacles();
BuildGrass();
// Ратуша и остальные здания -- из сохранения (или новая Ратуша в центре): в первом кадре, когда сохранение
// уже загружено (оно грузится в Start), см. ApplySaved
RefreshGround();
}
static Material Mat(Color color) => Map3D.Mat(color);
void BuildGround()
{
int n = City3DLayout.Size;
var verts = new Vector3[(n + 1) * (n + 1)];
var uvs = new Vector2[verts.Length];
for (int z = 0; z <= n; z++)
for (int x = 0; x <= n; x++)
{
int i = z * (n + 1) + x;
verts[i] = new Vector3(x, City3DLayout.HeightAt(x, z), z);
uvs[i] = new Vector2(x / (float)n, z / (float)n);
}
var tris = new int[n * n * 6];
int t = 0;
for (int z = 0; z < n; z++)
for (int x = 0; x < n; x++)
{
int i = z * (n + 1) + x;
tris[t++] = i; tris[t++] = i + n + 1; tris[t++] = i + 1;
tris[t++] = i + 1; tris[t++] = i + n + 1; tris[t++] = i + n + 2;
}
var mesh = new Mesh { name = "City3D_Ground", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.vertices = verts;
mesh.uv = uvs;
mesh.triangles = tris;
mesh.RecalculateNormals();
var go = new GameObject("Ground");
go.transform.SetParent(transform, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
groundTexture = new Texture2D(n * PixelsPerCell, n * PixelsPerCell, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp
};
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(groundTexture);
go.AddComponent<MeshCollider>().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);
var forest = new System.Random(7);
for (int i = 0; i < 140; 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));
}
// Вода -- плоскость над руслом
var water = GameObject.CreatePrimitive(PrimitiveType.Cube);
water.name = "River";
Destroy(water.GetComponent<Collider>());
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.GetComponent<Renderer>().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f));
}
void BuildObstacles()
{
int i = 0;
foreach (var o in layout.Obstacles)
{
Vector2 center = Vector2.zero;
foreach (var c in o.Cells) center += new Vector2(c.x + 0.5f, c.y + 0.5f);
center /= o.Cells.Count;
var ground = new Vector3(center.x, City3DLayout.HeightAt(center.x, center.y), center.y);
i++;
var view = o.IsTree
? Map3D.Tree(transform, o.Id, ground, 1.0f + (i * 37 % 10) * 0.05f, i) // ели и лиственные вперемешку
: Map3D.Rock(transform, o.Id, ground, o.Cells.Count > 1 ? 1.7f : 0.85f, i);
obstacleViews[o.Id] = view.gameObject;
}
}
// Трава кустиками -- только украшение: прячется под зданием и возвращается
void BuildGrass()
{
Color[] greens = { new Color(0.3f, 0.6f, 0.25f), new Color(0.38f, 0.66f, 0.28f), new Color(0.45f, 0.68f, 0.3f) };
for (int i = 0; i < layout.Grass.Count; i++)
{
Vector2 p = layout.Grass[i];
var g = Map3D.Shape(transform, "Grass", Map3D.Cone, greens[i % greens.Length],
new Vector3(p.x, City3DLayout.HeightAt(p.x, p.y), p.y), Vector2.one * 0.28f, 0.22f + (i % 3) * 0.06f).gameObject;
grassViews.Add(g);
}
}
static GameObject Part(GameObject parent, PrimitiveType type, Vector3 localPos, Vector3 scale, Material mat)
{
var p = GameObject.CreatePrimitive(type);
Object.Destroy(p.GetComponent<Collider>());
p.transform.SetParent(parent.transform, false);
p.transform.localPosition = localPos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = mat;
return p;
}
// Текстура земли: обычная -- трава, скалы, русло; в режиме стройки -- клетки, открытая зона и следующая
void RefreshGround()
{
int n = City3DLayout.Size, px = PixelsPerCell;
bool grid = mode != Mode.View || DebugGrid;
var pixels = new Color32[n * px * n * px];
for (int y = 0; y < n; y++)
for (int x = 0; x < n; x++)
{
CellKind k = map.KindAt(x, y);
bool shore = k == CellKind.Ground && (map.KindAt(x - 1, y) == CellKind.Water || map.KindAt(x + 1, y) == CellKind.Water
|| map.KindAt(x, y - 1) == CellKind.Water || map.KindAt(x, y + 1) == CellKind.Water);
float hm = map.HeightAt(x, y) / 1000f;
Color c = k == CellKind.Water ? new Color(0.32f, 0.5f, 0.72f)
: k == CellKind.Mountain ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((hm - 3.2f) / 1.5f)) // снег на вершинах
: shore ? 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(x * 0.25f, y * 0.25f));
if (grid && k == CellKind.Ground)
{
int zone = map.ZoneAt(x, y);
if (zone > 0 && zone <= townHallLevel) c = Color.Lerp(c, new Color(0.6f, 0.95f, 0.6f), 0.25f); // открыто
else if (zone == townHallLevel + 1) c = Color.Lerp(c, new Color(0.95f, 0.85f, 0.4f), 0.3f); // откроется следующим
else c *= 0.55f; // закрыто
}
bool open = map.IsOpen(x, y, townHallLevel);
for (int iy = 0; iy < px; iy++)
for (int ix = 0; ix < px; ix++)
{
Color p = c;
if (grid && (ix == 0 || iy == 0)) p *= DebugGrid ? 0.55f : 0.85f; // линии клеток
if (DebugGrid && ((ix == 0 && x % 5 == 0) || (iy == 0 && y % 5 == 0))) p = new Color(1f, 0.85f, 0.2f); // каждая 5-я
// граница открытой зоны -- светлая линия
if (grid && open && (ix == 0 && !map.IsOpen(x - 1, y, townHallLevel) || ix == px - 1 && !map.IsOpen(x + 1, y, townHallLevel)
|| iy == 0 && !map.IsOpen(x, y - 1, townHallLevel) || iy == px - 1 && !map.IsOpen(x, y + 1, townHallLevel)))
p = new Color(1f, 1f, 1f);
p.a = 1;
pixels[(y * px + iy) * n * px + x * px + ix] = p;
}
}
groundTexture.SetPixels32(pixels);
groundTexture.Apply(false);
}
// ---------------- Здания ----------------
bool PlaceNew(int type, int x, int y, int rot)
{
var t = Types[type];
string id = "b_" + nextBuildingId;
PlaceError e = map.Place(id, t.Name, x, y, t.W, t.H, rot, townHallLevel);
if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return false; }
nextBuildingId++;
buildingType[id] = type;
buildingViews[id] = CreateBuildingView(id, type);
City3DGameplay.Attach(buildingViews[id], t.Name, 1); // здание игры: окно, рабочие, уровни (без цены и таймера)
UpdateBuildingView(id);
UpdateGrass();
return true;
}
// Кровля по типу здания
static Color RoofFor(int type)
{
switch (Types[type].Name)
{
case "Ратуша": return new Color(0.65f, 0.15f, 0.12f);
case "Госпиталь": return new Color(0.45f, 0.55f, 0.65f);
case "Казармы": return new Color(0.4f, 0.15f, 0.12f);
case "Каменоломня":
case "Шахта":
case "Золотой рудник": return new Color(0.35f, 0.35f, 0.38f);
case "Кристалл": return new Color(0.25f, 0.4f, 0.6f);
case "Терем": return new Color(0.75f, 0.2f, 0.15f);
default: return Color.Lerp(Types[type].Color, new Color(0.3f, 0.15f, 0.1f), 0.55f);
}
}
// Здание на примитивах: стены и двускатная крыша в «единичном» объёме Body (растягивается под габарит и высоту),
// у некоторых -- свои приметы: башня Ратуши, флаг Казарм, крест Госпиталя, тёмный вход Шахты
GameObject CreateBuildingView(string id, int type)
{
var t = Types[type];
var root = new GameObject(id + "_" + t.Name);
root.transform.SetParent(transform, false);
var body = new GameObject("Body").transform;
body.SetParent(root.transform, false);
var hit = body.gameObject.AddComponent<BoxCollider>(); // тап по зданию попадает в него целиком
hit.center = new Vector3(0, 0.5f, 0);
hit.size = Vector3.one;
Color walls = Color.Lerp(t.Color, Color.white, 0.3f), roofColor = RoofFor(type);
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.31f, 0), new Vector3(1f, 0.62f, 1f), walls);
var top = Map3D.Shape(body, "Roof", Map3D.Roof, roofColor, Vector3.zero, new Vector2(1.08f, 1.12f), 0.38f).transform;
top.localPosition = new Vector3(0, 0.62f, 0);
switch (t.Name)
{
case "Ратуша":
Unit(body, PrimitiveType.Cube, new Vector3(0.3f, 0.7f, 0.3f), new Vector3(0.28f, 1.4f, 0.28f), walls);
var spire = Map3D.Shape(body, "Spire", Map3D.Pyramid, roofColor, Vector3.zero, Vector2.one * 0.4f, 0.4f, 45f).transform;
spire.localPosition = new Vector3(0.3f, 1.4f, 0.3f);
break;
case "Казармы":
Unit(body, PrimitiveType.Cylinder, new Vector3(-0.4f, 0.75f, 0.4f), new Vector3(0.03f, 0.75f, 0.03f), new Color(0.35f, 0.25f, 0.15f));
Unit(body, PrimitiveType.Cube, new Vector3(-0.32f, 1.35f, 0.4f), new Vector3(0.16f, 0.12f, 0.01f), new Color(0.8f, 0.15f, 0.15f));
break;
case "Госпиталь":
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.08f, 0.28f, 0.01f), new Color(0.85f, 0.1f, 0.1f));
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.38f, -0.505f), new Vector3(0.24f, 0.08f, 0.01f), new Color(0.85f, 0.1f, 0.1f));
break;
case "Шахта":
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.2f, -0.505f), new Vector3(0.35f, 0.4f, 0.02f), new Color(0.1f, 0.1f, 0.12f));
break;
case "Золотой рудник":
Unit(body, PrimitiveType.Cube, new Vector3(0, 0.2f, -0.505f), new Vector3(0.35f, 0.4f, 0.02f), new Color(0.1f, 0.1f, 0.12f));
Unit(body, PrimitiveType.Cube, new Vector3(0.32f, 0.08f, -0.62f), new Vector3(0.18f, 0.16f, 0.18f), new Color(1f, 0.82f, 0.2f)); // самородок у входа
break;
case "Кристалл":
var gem = Map3D.Shape(body, "Gem", Map3D.Pyramid, new Color(0.35f, 0.85f, 1f), Vector3.zero, Vector2.one * 0.45f, 0.7f, 45f).transform;
gem.localPosition = new Vector3(0, 1.0f, 0);
break;
case "Терем":
Unit(body, PrimitiveType.Cube, new Vector3(-0.3f, 0.75f, -0.3f), new Vector3(0.3f, 1.5f, 0.3f), walls);
var dome = Map3D.Shape(body, "Dome", Map3D.Pyramid, new Color(0.95f, 0.75f, 0.2f), Vector3.zero, Vector2.one * 0.42f, 0.45f, 45f).transform;
dome.localPosition = new Vector3(-0.3f, 1.5f, -0.3f);
break;
case "Ферма":
// грядки перед амбаром
for (int i = 0; i < 3; i++)
Unit(body, PrimitiveType.Cube, new Vector3(-0.3f + i * 0.3f, 0.02f, -0.62f), new Vector3(0.22f, 0.04f, 0.2f), new Color(0.55f, 0.75f, 0.25f));
break;
}
var door = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.28f, 0.18f, 0.1f)));
door.name = "Door"; // показывает, куда повёрнуто здание
var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.62f, 0.58f, 0.52f)));
foundation.name = "Foundation";
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(root.transform, false);
var label = labelGo.AddComponent<TextMeshPro>();
label.text = t.Name;
label.fontSize = 4;
label.alignment = TextAlignmentOptions.Center;
label.color = Color.white;
label.outlineWidth = 0.2f;
label.outlineColor = Color.black;
label.rectTransform.sizeDelta = new Vector2(8, 2);
return root;
}
// Деталь в единичном объёме здания (Body растягивается под габарит и высоту)
static void Unit(Transform body, PrimitiveType type, Vector3 localPos, Vector3 scale, Color color)
{
var p = GameObject.CreatePrimitive(type);
Destroy(p.GetComponent<Collider>());
p.transform.SetParent(body, false);
p.transform.localPosition = localPos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = Mat(color);
}
// Положение, поворот, фундамент по данным карты (или призрака при переносе)
void UpdateBuildingView(string id) { if (map.TryGetBuilding(id, out var r)) LayoutView(buildingViews[id], buildingType[id], r.X, r.Y, r.Rotation); }
void LayoutView(GameObject root, int type, int x, int y, int rot)
{
var t = Types[type];
bool swap = rot == 90 || rot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
int min = int.MaxValue, max = int.MinValue;
for (int cy = y; cy < y + h; cy++)
for (int cx = x; cx < x + w; cx++) { int v = map.HeightAt(cx, cy); min = Mathf.Min(min, v); max = Mathf.Max(max, v); }
if (min == int.MaxValue) { min = 0; max = 0; }
float top = max / 1000f, bottom = min / 1000f - 0.3f;
root.transform.position = new Vector3(x + w / 2f, top, y + h / 2f);
// Body -- в размерах без поворота и повёрнут сам: конёк крыши и приметы поворачиваются вместе со зданием
var body = root.transform.Find("Body");
body.localScale = new Vector3(t.W - 0.3f, t.Height, t.H - 0.3f);
body.localPosition = Vector3.zero;
body.localRotation = Quaternion.Euler(0, -rot, 0);
var foundation = root.transform.Find("Foundation");
foundation.localScale = new Vector3(w - 0.1f, top - bottom, h - 0.1f);
foundation.localPosition = new Vector3(0, (bottom - top) / 2f, 0);
// дверь -- на «переднюю» сторону: 0 -- юг, 90 -- запад, 180 -- север, 270 -- восток
var door = root.transform.Find("Door");
Vector3 dir = Quaternion.Euler(0, -rot, 0) * Vector3.back;
float halfDepth = (rot == 0 || rot == 180 ? h : w) / 2f - 0.15f;
door.localPosition = dir * halfDepth + Vector3.up * 0.4f;
door.localScale = rot == 0 || rot == 180 ? new Vector3(0.6f, 0.8f, 0.1f) : new Vector3(0.1f, 0.8f, 0.6f);
var label = root.transform.Find("Label");
label.localPosition = new Vector3(0, t.Height + 0.8f, 0);
}
void UpdateGrass()
{
for (int i = 0; i < grassViews.Count; i++)
{
Vector2 p = layout.Grass[i];
bool hidden = map.BuildingAt((int)p.x, (int)p.y) != null;
// и под призраком при переносе: прежнее место -- трава вернётся только после подтверждения
if (!hidden && mode == Mode.Move && movingId != null && map.TryGetBuilding(movingId, out var r))
hidden = (int)p.x >= r.X && (int)p.x < r.X + r.W && (int)p.y >= r.Y && (int)p.y < r.Y + r.H;
grassViews[i].SetActive(!hidden);
}
}
// ---------------- Призрак: новое здание или перенос ----------------
void StartPlacing(int type)
{
CancelGhost();
mode = Mode.Place;
placingType = type;
var t = Types[type];
ghost = CreateBuildingView("ghost", type);
ghostRot = 0;
Vector3 c = cameraRig.target;
ghostX = Mathf.Clamp(Mathf.RoundToInt(c.x - t.W / 2f), 0, City3DLayout.Size - t.W);
ghostY = Mathf.Clamp(Mathf.RoundToInt(c.z - t.H / 2f), 0, City3DLayout.Size - t.H);
// Не на Ратуше и не в чужом здании: ближайшее к центру экрана место, где здание можно поставить
if (FindFreeSpot(t, ghostX, ghostY, out int fx, out int fy, out int frot))
{
ghostX = fx; ghostY = fy; ghostRot = frot;
// камера плавно подъезжает к месту, не меняя приближения
cameraRig.MoveTo(new Vector3(fx + t.W / 2f, 0, fy + t.H / 2f));
}
EnsureFootprint();
RefreshGround();
UpdateGhost();
}
// Ближайшее свободное место (по кольцам от x0, y0), сначала без поворота, потом с поворотом на 90
bool FindFreeSpot(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; // только кольцо радиуса r
foreach (int tryRot in new[] { 0, 90 })
if (map.Validate(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; // места нет -- призрак там, где камера, красный с причиной
}
void StartMoving(string id)
{
if (!map.TryGetBuilding(id, out var r)) return;
CancelGhost();
mode = Mode.Move;
movingId = id;
moveOriginal = r;
ghostX = r.X; ghostY = r.Y; ghostRot = r.Rotation;
ghost = buildingViews[id]; // переносится ТО ЖЕ здание -- новое не создаётся
EnsureFootprint();
RefreshGround();
UpdateGhost();
}
void EnsureFootprint()
{
if (footprint != null) return;
footprint = GameObject.CreatePrimitive(PrimitiveType.Cube);
footprint.name = "Footprint";
Destroy(footprint.GetComponent<Collider>());
footprint.transform.SetParent(transform, false);
}
int GhostType => mode == Mode.Move ? buildingType[movingId] : placingType;
void UpdateGhost()
{
if (ghost == null) return;
var t = Types[GhostType];
ghostError = map.Validate(ghostX, ghostY, t.W, t.H, ghostRot, townHallLevel, mode == Mode.Move ? movingId : null, out ghostFoundation);
LayoutView(ghost, GhostType, ghostX, ghostY, ghostRot);
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
float top = ghost.transform.position.y;
footprint.transform.position = new Vector3(ghostX + w / 2f, top + 0.03f, ghostY + h / 2f);
footprint.transform.localScale = new Vector3(w, 0.06f, h);
if (okMaterial == null) { okMaterial = Mat(new Color(0.3f, 0.95f, 0.35f)); badMaterial = Mat(new Color(0.95f, 0.25f, 0.2f)); }
footprint.GetComponent<Renderer>().sharedMaterial = ghostError == PlaceError.None ? okMaterial : badMaterial;
UpdateGrass();
}
void ConfirmGhost()
{
if (ghostError != PlaceError.None) { Say(SettlementMap.ErrorText(ghostError)); return; }
if (mode == Mode.Place)
{
int type = placingType;
int x = ghostX, y = ghostY, rot = ghostRot;
string problem = City3DGameplay.CanBuild(Types[type].Name); // уровень Ратуши, лимит, строители, ресурсы
if (problem != null) { Say(problem); return; }
string id = "b_" + nextBuildingId;
CancelGhost();
if (PlaceNew(type, x, y, rot))
{
City3DGameplay.StartConstruction(buildingViews[id]); // цена и стройка по времени (BuildTimer)
Say(Types[type].Name + ": стройка началась");
}
}
else if (mode == Mode.Move)
{
// Подтверждение -- одно изменение: позиция и поворот того же здания; ошибка -- всё как было
PlaceError e = map.Move(movingId, ghostX, ghostY, ghostRot, townHallLevel);
string id = movingId;
if (e != PlaceError.None) { Say(SettlementMap.ErrorText(e)); return; }
movingId = null;
ghost = null;
mode = Mode.Edit;
UpdateBuildingView(id);
Say("Перенесено. Здание то же: уровень, рабочие и таймеры не меняются");
FinishGhost();
}
}
// Отмена: новое -- не создаётся; перенос -- здание возвращается на последнее подтверждённое место
void CancelGhost()
{
if (mode == Mode.Place && ghost != null) Destroy(ghost);
if (mode == Mode.Move && movingId != null) UpdateBuildingView(movingId);
bool wasMove = mode == Mode.Move;
ghost = null;
movingId = null;
placingType = -1;
mode = wasMove ? Mode.Edit : Mode.View;
FinishGhost();
}
void FinishGhost()
{
if (footprint != null) { Destroy(footprint); footprint = null; }
draggingGhost = false;
cameraRig.AllowPan = true;
UpdateGrass();
RefreshGround();
}
// ---------------- Ввод ----------------
void Update()
{
if (!stateApplied && map != null) ApplySaved();
if (cam == null) return;
UpdateConstructionViews();
bool down, held, up;
Vector3 pos;
if (Input.touchCount > 0)
{
// два пальца -- зум камеры. Касание считается перетаскиванием: когда пальцы отпустят, это не тап
// (иначе после щипка открывалось окно здания под оставшимся пальцем) (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;
up = touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled;
held = !down && !up;
}
else
{
pos = Input.mousePosition;
down = Input.GetMouseButtonDown(0);
up = Input.GetMouseButtonUp(0);
held = Input.GetMouseButton(0) && !down;
}
if (down)
{
pointerOverGui = OverGui(pos) || City3DInput.OverHud(pos); // и кнопки/окна общего HUD
if (pointerOverGui) return;
pointerDown = pos;
pointerMoved = false;
draggingGhost = (mode == Mode.Place || mode == Mode.Move) && CellUnder(pos, true, out int cx, out int cy) && InsideGhost(cx, cy);
cameraRig.AllowPan = !draggingGhost;
cameraRig.BeginPan(pos);
}
else if (held && !pointerOverGui)
{
if ((pos - pointerDown).magnitude > Screen.height * 0.015f) pointerMoved = true;
if (draggingGhost) DragGhost(pos);
else if (pointerMoved) cameraRig.Pan(pos); // карта едет только при настоящем перетаскивании
else cameraRig.Track(pos); // а мелкое дрожание пальца при тапе её не трогает
}
else if (up && !pointerOverGui)
{
cameraRig.EndPan();
if (!pointerMoved && !draggingGhost) Tap(pos);
draggingGhost = false;
cameraRig.AllowPan = true;
}
}
void DragGhost(Vector3 pos)
{
if (!CellUnder(pos, true, out int cx, out int cy)) return;
var t = Types[GhostType];
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
int nx = Mathf.Clamp(cx - w / 2, 0, City3DLayout.Size - w), ny = Mathf.Clamp(cy - h / 2, 0, City3DLayout.Size - h);
if (nx == ghostX && ny == ghostY) return;
ghostX = nx; ghostY = ny;
UpdateGhost();
}
bool InsideGhost(int cx, int cy)
{
if (ghost == null) return false;
var t = Types[GhostType];
bool swap = ghostRot == 90 || ghostRot == 270;
int w = swap ? t.H : t.W, h = swap ? t.W : t.H;
return cx >= ghostX - 1 && cx < ghostX + w + 1 && cy >= ghostY - 1 && cy < ghostY + h + 1; // с запасом на палец
}
void Tap(Vector3 pos)
{
if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return;
if (mode == Mode.Place || mode == Mode.Move)
{
// тап по земле -- переставить призрак туда
DragGhost(pos);
return;
}
string building = map.BuildingAt(cx, cy);
if (mode == Mode.Edit && building != null) { StartMoving(building); return; }
// Тап по зданию -- окно здания игры (как в 2D): рабочие, добыча, улучшение, перенос
if (building != null && buildingViews.TryGetValue(building, out var tapped) && tapped != null)
{
selectedBuilding = selectedObstacle = null;
buildMenuOpen = false;
BuildingWindow.Open(tapped);
return;
}
selectedBuilding = building;
selectedObstacle = building == null ? map.ObstacleAt(cx, cy) : null;
// Расчищать можно только в открытой зоне стройки (за её пределами -- после улучшения Ратуши) (05.11)
if (selectedObstacle != null && !map.IsOpen(cx, cy, townHallLevel))
{
selectedObstacle = null;
Say("Это за пределами зоны стройки -- откроется после улучшения Ратуши");
}
}
// Клетка под пальцем. groundOnly -- только земля (перетаскивание призрака); иначе здание под пальцем -- его клетка
bool CellUnder(Vector3 screen, bool groundOnly, out int x, out int y)
{
x = y = -1;
Ray ray = cam.ScreenPointToRay(screen);
var hits = Physics.RaycastAll(ray, 500f);
System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance));
foreach (var hit in hits)
{
Transform root = hit.collider.transform.parent;
if (!groundOnly && root != null)
foreach (var pair in buildingViews)
if (pair.Value != null && pair.Value.transform == root && map.TryGetBuilding(pair.Key, out var r))
{
x = r.X; y = r.Y;
return true;
}
if (hit.collider.gameObject.name != "Ground") continue;
x = Mathf.FloorToInt(hit.point.x);
y = Mathf.FloorToInt(hit.point.z);
return map.InMap(x, y);
}
return false;
}
// Подписи зданий всегда смотрят в камеру
void LateUpdate()
{
if (cam == null) return;
foreach (var v in buildingViews.Values)
if (v != null) { var l = v.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; }
if (ghost != null) { var l = ghost.transform.Find("Label"); if (l != null) l.rotation = cam.transform.rotation; }
}
// Кнопки "Ратуша −/+" -- только для теста: меняют уровень настоящей Ратуши (TownHall)
void ChangeTownHall(int delta)
{
var th = TownHall.Instance;
int level = Mathf.Clamp((th != null ? th.currentLevel : townHallLevel) + delta, 1, th != null ? th.maxLevel : 5);
if (th != null)
{
th.currentLevel = level;
EventBus.Publish(new TownHallLevelChangedEvent { Level = level }); // зоны обновит OnTownHallLevel
}
else ApplyTownHallLevel(level);
}
// Уровень Ратуши изменился (улучшение в окне Ратуши или тест): открыть зону
void ApplyTownHallLevel(int level)
{
int before = townHallLevel;
townHallLevel = Mathf.Clamp(level, 1, 5);
if (townHallLevel == before) return;
UpdateCameraArea();
RefreshGround();
if (ghost != null) UpdateGhost();
if (townHallLevel > before)
{
// Открылась зона -- камера плавно отходит и показывает её КРАЙ: старая зона и ZoneEdgeCells клеток новой за её
// границей (06.11, Саша: вся зона целиком -- камера улетала слишком далеко). Ближе, чем была, не подъезжает
int c = City3DLayout.Size / 2;
int show = Mathf.Min(City3DLayout.ZoneHalfSize(townHallLevel), City3DLayout.ZoneHalfSize(before) + ZoneEdgeCells);
cameraRig.FocusOn(new Rect(c - show, c - show, show * 2, show * 2), onlyZoomOut: true);
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
if (stateApplied && pendingFrom2D.Count > 0) PlacePendingFrom2D(); // здания из 2D, которые ждали места
}
}
const int ZoneEdgeCells = 2; // сколько клеток новой зоны показать при её открытии
// Камера: центр кадра -- только над открытой зоной, при самом дальнем отдалении видна вся зона (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;
messageUntil = Time.unscaledTime + 4f;
}
// ---------------- Кнопки (временные, IMGUI) ----------------
readonly List<Rect> guiRects = new List<Rect>();
float guiScale = 1f;
GUIStyle button, label, box;
// ---------------- Стройка по времени в 3D ----------------
// BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым
// (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11)
readonly Dictionary<string, TextMeshPro> constructionLabels = new Dictionary<string, TextMeshPro>();
static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"), ColorId = Shader.PropertyToID("_Color");
MaterialPropertyBlock constructionBlock;
void UpdateConstructionViews()
{
foreach (var pair in buildingViews)
{
var view = pair.Value;
if (view == null) continue;
var timer = BuildTimer.Of(view);
bool building = timer != null && BuildTimer.IsConstructing(view.transform);
constructionLabels.TryGetValue(pair.Key, out var label);
if (building)
{
if (label == null) label = constructionLabels[pair.Key] = StartConstructionLook(view);
label.text = "Стройка " + GameData.FormatTime(timer.SecondsLeft);
label.transform.rotation = cam.transform.rotation;
}
else if (label != null)
{
Destroy(label.gameObject);
constructionLabels.Remove(pair.Key);
foreach (var r in view.GetComponentsInChildren<Renderer>()) if (!(r is TMPro.TMP_SubMesh)) r.SetPropertyBlock(null);
}
}
}
TextMeshPro StartConstructionLook(GameObject view)
{
if (constructionBlock == null) constructionBlock = new MaterialPropertyBlock();
Bounds bounds = new Bounds(view.transform.position, Vector3.zero);
bool first = true;
foreach (var r in view.GetComponentsInChildren<Renderer>())
{
if (first) { bounds = r.bounds; first = false; } else bounds.Encapsulate(r.bounds);
var m = r.sharedMaterial;
if (m == null) continue;
Color c = m.HasProperty(BaseColorId) ? m.GetColor(BaseColorId) : m.HasProperty(ColorId) ? m.GetColor(ColorId) : Color.white;
Color grey = Color.Lerp(c, new Color(0.45f, 0.42f, 0.38f), 0.6f) * 0.75f;
grey.a = 1f;
r.GetPropertyBlock(constructionBlock);
constructionBlock.SetColor(BaseColorId, grey);
constructionBlock.SetColor(ColorId, grey);
r.SetPropertyBlock(constructionBlock);
}
var go = new GameObject("ConstructionLabel");
go.transform.SetParent(view.transform, false);
go.transform.position = new Vector3(bounds.center.x, bounds.max.y + 0.7f, bounds.center.z);
var label = go.AddComponent<TextMeshPro>();
label.fontSize = 7f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(8f, 1.5f);
label.color = new Color(1f, 0.85f, 0.4f);
label.outlineWidth = 0.25f;
label.outlineColor = Color.black;
return label;
}
// Открыто модальное окно UIKit (окно здания, отчёт, подтверждение) -- проверка раз в кадр
int modalCheckedFrame = -1;
bool modalOpen;
bool ModalOpen()
{
if (modalCheckedFrame != Time.frameCount)
{
modalCheckedFrame = Time.frameCount;
modalOpen = GameObject.Find("Modal") != null;
}
return modalOpen;
}
bool OverGui(Vector3 screen)
{
var p = new Vector2(screen.x / guiScale, (Screen.height - screen.y) / guiScale);
foreach (var r in guiRects) if (r.Contains(p)) return true;
return false;
}
bool Button(Rect r, string text)
{
guiRects.Add(r);
return GUI.Button(r, text, button);
}
void Box(Rect r, string text)
{
guiRects.Add(r);
GUI.Box(r, text, box);
}
void OnGUI()
{
guiScale = Screen.height / 720f;
GUI.matrix = Matrix4x4.Scale(new Vector3(guiScale, guiScale, 1));
float w = Screen.width / guiScale, h = 720f;
if (button == null)
{
button = new GUIStyle(GUI.skin.button) { fontSize = 20, wordWrap = true };
label = new GUIStyle(GUI.skin.label) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter };
label.normal.textColor = Color.white;
box = new GUIStyle(GUI.skin.box) { fontSize = 20, wordWrap = true, alignment = TextAnchor.MiddleCenter };
box.normal.textColor = Color.white;
}
if (Event.current.type == EventType.Layout) guiRects.Clear();
// Окно HUD открыто -- временные кнопки сцены не показываем (и не ловим нажатия)
if (UIPanelManager.Instance != null && UIPanelManager.Instance.CurrentId != "") return;
if (ModalOpen()) return; // и окно здания / сообщение (UIKit.Modal): тестовые кнопки сцены не рисуются поверх него (05.11)
// Под верхней панелью HUD, справа: Ратуша −/+ -- только в редакторе и отладочной сборке (тест зон), редактирование
if (Application.isEditor || Debug.isDebugBuild)
{
Box(new Rect(w - 480, HudTop, 290, 44), $"3D (тест) • Ратуша {townHallLevel} ур.");
if (Button(new Rect(w - 185, HudTop, 55, 44), "−")) ChangeTownHall(-1);
if (Button(new Rect(w - 125, HudTop, 55, 44), "+")) ChangeTownHall(+1);
}
if (mode == Mode.View || mode == Mode.Edit)
{
if (Button(new Rect(w - 250, HudTop + 50, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать"))
{
mode = mode == Mode.Edit ? Mode.View : Mode.Edit;
selectedBuilding = selectedObstacle = null;
RefreshGround();
if (mode == Mode.Edit) Say("Режим редактирования: нажмите здание, чтобы перенести");
}
}
// Справа по центру: приближение / отдаление. На телефоне есть щипок двумя пальцами, но в редакторе
// (Device Simulator) колесо мыши не доходит до игры, а щипок мышью не сделать -- без кнопок карту не отдалить (05.11)
if (Button(new Rect(w - 70, h / 2 - 62, 60, 56), "+")) cameraRig.Zoom(0.2f);
if (Button(new Rect(w - 70, h / 2 + 2, 60, 56), "−")) cameraRig.Zoom(-0.25f);
// Слева, правее кнопок HUD: что построить -- открывается кнопкой "СТРОЙКА" общего HUD
if (buildMenuOpen && (mode == Mode.View || mode == Mode.Edit))
{
float y = HudTop, x = HudLeft;
for (int i = 1; i < Types.Length; i++)
{
if (y + 40 > h - HudBottom) { y = HudTop; x += 206; } // не помещается до нижней панели -- следующий столбец
if (Button(new Rect(x, y, 200, 40), $"{Types[i].Name} {Types[i].W}×{Types[i].H}")) { buildMenuOpen = false; StartPlacing(i); }
y += 44;
}
}
// Низ: призрак -- поворот, поставить, отмена
if (mode == Mode.Place || mode == Mode.Move)
{
string status = ghostError == PlaceError.None
? (ghostFoundation > 0 ? $"Можно ставить (фундамент {ghostFoundation / 1000f:0.0} м)" : "Можно ставить")
: SettlementMap.ErrorText(ghostError);
Box(new Rect(w / 2 - 300, h - HudBottom - 120, 600, 44), (mode == Mode.Move ? "Перенос: " : Types[GhostType].Name + ": ") + status);
if (Button(new Rect(w / 2 - 300, h - HudBottom - 70, 190, 60), "Повернуть 90°")) { ghostRot = (ghostRot + 90) % 360; UpdateGhost(); }
if (Button(new Rect(w / 2 - 95, h - HudBottom - 70, 190, 60), mode == Mode.Move ? "Подтвердить" : "Поставить")) ConfirmGhost();
if (Button(new Rect(w / 2 + 110, h - HudBottom - 70, 190, 60), "Отмена")) CancelGhost();
}
// Меню здания: перенос
if (mode == Mode.View && selectedBuilding != null && map.TryGetBuilding(selectedBuilding, out var b))
{
Box(new Rect(w / 2 - 250, h - HudBottom - 120, 500, 44), $"{b.Type} • {b.W}×{b.H} клеток • поворот {b.Rotation}°");
if (Button(new Rect(w / 2 - 250, h - HudBottom - 70, 245, 60), "Переместить")) { string id = selectedBuilding; selectedBuilding = null; StartMoving(id); }
if (Button(new Rect(w / 2 + 5, h - HudBottom - 70, 245, 60), "Закрыть")) selectedBuilding = null;
}
// Меню дерева / валуна: расчистка без цены и ожидания
if (mode == Mode.View && selectedObstacle != null)
{
bool tree = selectedObstacle.StartsWith("tree");
Box(new Rect(w / 2 - 250, h - HudBottom - 120, 500, 44), tree ? "Дерево" : "Валун");
if (Button(new Rect(w / 2 - 250, h - HudBottom - 70, 245, 60), "Расчистить"))
{
if (map.ClearObstacle(selectedObstacle) && obstacleViews.TryGetValue(selectedObstacle, out var view))
{
Destroy(view);
City3DGameplay.RewardClearing(tree); // +10 дерева или камня, один раз (решение 04.11)
Say((tree ? "Дерево" : "Валун") + " расчищено: +10 " + (tree ? "дерева" : "камня"));
}
selectedObstacle = null;
}
if (Button(new Rect(w / 2 + 5, h - HudBottom - 70, 245, 60), "Закрыть")) selectedObstacle = null;
}
if (Time.unscaledTime < messageUntil)
GUI.Label(new Rect(w / 2 - 350, HudTop, 700, 60), message, label);
}
}