Files
MVP1/Assets/Scripts/City3D/City3DController.cs
T

904 lines
45 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) },
};
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();
}
bool stateApplied; // сохранённый город уже поставлен (в первом кадре, после загрузки сохранения в Start)
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()
{
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);
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);
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); // новый город: Ратуша в центре
UpdateGrass();
RefreshGround();
if (CitySync.Instance != null) CitySync.Instance.SyncNow();
}
// Кнопка "Перенести" в окне здания
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);
void Start()
{
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);
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;
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 *= 0.85f; // линии клеток
// граница открытой зоны -- светлая линия
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 "Шахта": return new Color(0.35f, 0.35f, 0.38f);
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 "Ферма":
// грядки перед амбаром
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);
EnsureFootprint();
RefreshGround();
UpdateGhost();
}
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;
bool down, held, up;
Vector3 pos;
if (Input.touchCount > 0)
{
if (Input.touchCount > 1) { cameraRig.EndPan(); draggingGhost = false; 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) || UIBlocker.IsPointerOverUI(); // и кнопки/окна общего 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 cameraRig.Pan(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;
}
// Клетка под пальцем. 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;
RefreshGround();
if (ghost != null) UpdateGhost();
if (townHallLevel > before)
{
// Открылась зона -- камера плавно отходит и показывает её; здания остаются на местах
int half = City3DLayout.ZoneHalfSize(townHallLevel), c = City3DLayout.Size / 2;
cameraRig.FocusOn(new Rect(c - half, c - half, half * 2, half * 2));
Say($"Ратуша {townHallLevel} ур.: открыта новая зона");
}
}
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;
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;
// Под верхней панелью HUD, справа: Ратуша (тест), редактирование
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("Режим редактирования: нажмите здание, чтобы перенести");
}
}
// Слева, правее кнопок HUD: что построить -- открывается кнопкой "СТРОЙКА" общего HUD
if (buildMenuOpen && (mode == Mode.View || mode == Mode.Edit))
{
float y = HudTop;
for (int i = 1; i < Types.Length; i++)
{
if (Button(new Rect(HudLeft, 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);
}
}