Files
MVP1/Assets/Scripts/City3D/City3DController.cs
T
AlexanderandClaude Opus 5.5 a3c4a99a79 3D-поселение, этап I: прототип на примитивах (ТЗ от 04.11)
Bogatyri.Settlement.Placement (без Unity): сетка, зоны по Ратуше, река/скалы/деревья/валуны,
отступ, уклон и фундамент, поворот 0/90/180/270, атомарный перенос с тем же id, расчистка один раз; 7 тестов.
Сцена City3D (Tools -> Bogatyri -> 3D-город): местность, призрак, перенос, расчистка, камера 2.5D,
второй рендерер URP (3D) только для её камеры -- 2D-сцены не тронуты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:50:22 +01:00

702 lines
32 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), здесь только показ и ввод.
//
// Пока НЕ подключено (этапы II-III): цена и таймер стройки, Экономика и награда за расчистку, сохранение,
// дороги, настоящие модели. Кнопки -- временные (IMGUI); окна согласуются с Володей.
public class City3DController : MonoBehaviour
{
// Тип здания для прототипа: максимальный габарит (клеток) с первого уровня, высота куба, цвет
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;
// ---------------- Построение сцены ----------------
void Start()
{
cam = Camera.main;
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();
// Материал URP по умолчанию берём у примитива: шейдер точно есть в сборке
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
baseMaterial = new Material(probe.GetComponent<Renderer>().sharedMaterial);
Destroy(probe);
layout = City3DLayout.Create();
map = layout.Map;
BuildGround();
BuildObstacles();
BuildGrass();
// Ратуша стоит в центре с самого начала
int c = City3DLayout.Size / 2;
PlaceNew(0, c - 2, c - 2, 0);
RefreshGround();
}
Material Mat(Color color)
{
var m = new Material(baseMaterial) { color = color };
return m;
}
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
};
var mat = Mat(Color.white);
mat.mainTexture = groundTexture;
go.AddComponent<MeshRenderer>().sharedMaterial = mat;
go.AddComponent<MeshCollider>().sharedMesh = mesh;
// Вода -- плоскость над руслом
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.25f, 0.5f, 0.85f));
}
void BuildObstacles()
{
var trunk = Mat(new Color(0.45f, 0.3f, 0.18f));
var leaves = Mat(new Color(0.18f, 0.48f, 0.2f));
var stone = Mat(new Color(0.55f, 0.55f, 0.58f));
foreach (var o in layout.Obstacles)
{
var root = new GameObject(o.Id);
root.transform.SetParent(transform, false);
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;
float h = City3DLayout.HeightAt(center.x, center.y);
root.transform.position = new Vector3(center.x, h, center.y);
if (o.IsTree)
{
Part(root, PrimitiveType.Cylinder, new Vector3(0, 0.5f, 0), new Vector3(0.25f, 0.5f, 0.25f), trunk);
Part(root, PrimitiveType.Sphere, new Vector3(0, 1.4f, 0), new Vector3(1.1f, 1.3f, 1.1f), leaves);
}
else
{
float s = o.Cells.Count > 1 ? 1.8f : 0.9f;
Part(root, PrimitiveType.Sphere, new Vector3(0, s * 0.3f, 0), new Vector3(s, s * 0.7f, s * 0.9f), stone);
}
obstacleViews[o.Id] = root;
}
}
void BuildGrass()
{
var grass = Mat(new Color(0.35f, 0.65f, 0.3f));
foreach (var p in layout.Grass)
{
var g = GameObject.CreatePrimitive(PrimitiveType.Cube);
Destroy(g.GetComponent<Collider>());
g.name = "Grass";
g.transform.SetParent(transform, false);
g.transform.position = new Vector3(p.x, City3DLayout.HeightAt(p.x, p.y) + 0.08f, p.y);
g.transform.localScale = new Vector3(0.25f, 0.16f, 0.25f);
g.transform.rotation = Quaternion.Euler(0, p.x * 37f, 0);
g.GetComponent<Renderer>().sharedMaterial = grass;
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);
Color c = k == CellKind.Water ? new Color(0.3f, 0.45f, 0.7f)
: k == CellKind.Mountain ? new Color(0.5f, 0.48f, 0.45f)
: Color.Lerp(new Color(0.35f, 0.58f, 0.3f), new Color(0.42f, 0.66f, 0.34f), Mathf.PerlinNoise(x * 0.3f, y * 0.3f));
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);
UpdateBuildingView(id);
UpdateGrass();
return true;
}
GameObject CreateBuildingView(string id, int type)
{
var t = Types[type];
var root = new GameObject(id + "_" + t.Name);
root.transform.SetParent(transform, false);
var body = GameObject.CreatePrimitive(PrimitiveType.Cube);
body.name = "Body";
body.transform.SetParent(root.transform, false);
body.GetComponent<Renderer>().sharedMaterial = Mat(t.Color);
var door = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.25f, 0.18f, 0.12f)));
door.name = "Door"; // показывает, куда повёрнуто здание
var foundation = Part(root, PrimitiveType.Cube, Vector3.zero, Vector3.one, Mat(new Color(0.6f, 0.57f, 0.5f)));
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;
}
// Положение, поворот, фундамент по данным карты (или призрака при переносе)
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);
var body = root.transform.Find("Body");
body.localScale = new Vector3(w - 0.3f, t.Height, h - 0.3f);
body.localPosition = new Vector3(0, t.Height / 2f, 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;
CancelGhost();
if (PlaceNew(type, x, y, rot)) 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 (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);
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; }
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; }
}
void ChangeTownHall(int delta)
{
int before = townHallLevel;
townHallLevel = Mathf.Clamp(townHallLevel + delta, 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();
// Верх: режим, Ратуша (тест), редактирование
Box(new Rect(10, 10, 330, 44), $"3D-поселение (прототип) • Ратуша {townHallLevel} ур.");
if (Button(new Rect(350, 10, 60, 44), "−")) ChangeTownHall(-1);
if (Button(new Rect(415, 10, 60, 44), "+")) ChangeTownHall(+1);
if (mode == Mode.View || mode == Mode.Edit)
{
if (Button(new Rect(w - 250, 10, 240, 44), mode == Mode.Edit ? "Готово" : "Редактировать"))
{
mode = mode == Mode.Edit ? Mode.View : Mode.Edit;
selectedBuilding = selectedObstacle = null;
RefreshGround();
if (mode == Mode.Edit) Say("Режим редактирования: нажмите здание, чтобы перенести");
}
}
// Слева: что построить
if (mode == Mode.View || mode == Mode.Edit)
{
float y = 64;
for (int i = 1; i < Types.Length; i++)
{
if (Button(new Rect(10, y, 200, 40), $"{Types[i].Name} {Types[i].W}×{Types[i].H}")) 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 - 120, 600, 44), (mode == Mode.Move ? "Перенос: " : Types[GhostType].Name + ": ") + status);
if (Button(new Rect(w / 2 - 300, h - 70, 190, 60), "Повернуть 90°")) { ghostRot = (ghostRot + 90) % 360; UpdateGhost(); }
if (Button(new Rect(w / 2 - 95, h - 70, 190, 60), mode == Mode.Move ? "Подтвердить" : "Поставить")) ConfirmGhost();
if (Button(new Rect(w / 2 + 110, h - 70, 190, 60), "Отмена")) CancelGhost();
}
// Меню здания: перенос
if (mode == Mode.View && selectedBuilding != null && map.TryGetBuilding(selectedBuilding, out var b))
{
Box(new Rect(w / 2 - 250, h - 120, 500, 44), $"{b.Type} • {b.W}×{b.H} клеток • поворот {b.Rotation}°");
if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Переместить")) { string id = selectedBuilding; selectedBuilding = null; StartMoving(id); }
if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedBuilding = null;
}
// Меню дерева / валуна: расчистка без цены и ожидания
if (mode == Mode.View && selectedObstacle != null)
{
bool tree = selectedObstacle.StartsWith("tree");
Box(new Rect(w / 2 - 250, h - 120, 500, 44), tree ? "Дерево" : "Валун");
if (Button(new Rect(w / 2 - 250, h - 70, 245, 60), "Расчистить"))
{
if (map.ClearObstacle(selectedObstacle) && obstacleViews.TryGetValue(selectedObstacle, out var view))
{
Destroy(view);
Say((tree ? "Дерево" : "Валун") + " расчищено. Награда " + (tree ? "деревом" : "камнем") +
" -- после подключения сохранений (этап III)");
}
selectedObstacle = null;
}
if (Button(new Rect(w / 2 + 5, h - 70, 245, 60), "Закрыть")) selectedObstacle = null;
}
if (Time.unscaledTime < messageUntil)
GUI.Label(new Rect(w / 2 - 350, 64, 700, 60), message, label);
}
}