Feature/3d hud #18

Merged
Alexander merged 59 commits from feature/3d-hud into develop 2026-11-06 18:09:33 +03:00
10 changed files with 268 additions and 135 deletions
Showing only changes of commit 9a6e5a2e7b - Show all commits
@@ -82,6 +82,7 @@ public class City3DController : MonoBehaviour
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);
+24 -36
View File
@@ -7,14 +7,17 @@ using UnityEngine;
// здесь только рисуем то, что он вернул.
//
// Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто.
// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects).
// 2.5D (04.11.2026): туман рисуется прямо в текстуре земли -- объекты (города, лагеря, точки) стоят над ней и видны всегда,
// как требует решение 31.10, п. 1. hideUnexploredObjects в 3D пока только сильнее затемняет землю.
// Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~min–max»
// и владельцем, если опознан; вышли из обзора -- бледная метка последнего контакта.
public class IntelligenceOverlay : MonoBehaviour
{
static readonly Color ObservedColor = new Color(0.9f, 0.25f, 0.2f);
static readonly Color GhostColor = new Color(0.9f, 0.25f, 0.2f, 0.35f);
const float MarkerSize = 0.55f;
static readonly Color GhostColor = new Color(0.75f, 0.55f, 0.55f); // бледная -- последний контакт
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color FogColor = new Color(0.04f, 0.055f, 0.08f);
const float MarkerSize = 0.55f, MarkerHeight = 0.5f;
const int CellSize = 1000;
private Transform mapRoot;
@@ -23,17 +26,19 @@ public class IntelligenceOverlay : MonoBehaviour
private Color32[] pixels;
private int gridW, gridH;
private float nextFogRefresh;
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> markers = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private Renderer ground;
private readonly Dictionary<string, (Renderer sprite, TextMeshPro label)> markers = new Dictionary<string, (Renderer, TextMeshPro)>();
private readonly HashSet<string> alive = new HashSet<string>();
private readonly List<string> stale = new List<string>();
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world)
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world, Renderer ground)
{
var go = new GameObject("Intelligence");
go.transform.SetParent(mapRoot, false);
var overlay = go.AddComponent<IntelligenceOverlay>();
overlay.mapRoot = mapRoot;
overlay.world = world;
overlay.ground = ground;
overlay.CreateFog();
return overlay;
}
@@ -48,16 +53,8 @@ public class IntelligenceOverlay : MonoBehaviour
wrapMode = TextureWrapMode.Clamp
};
pixels = new Color32[gridW * gridH];
var go = new GameObject("Fog");
go.transform.SetParent(transform, false);
var sr = go.AddComponent<SpriteRenderer>();
// 1 пиксель = 1 клетка карты = 1 единица Unity; центр спрайта -- центр карты
sr.sprite = Sprite.Create(fog, new Rect(0, 0, gridW, gridH), new Vector2(0.5f, 0.5f), 1f);
go.transform.position = new Vector3((gridW * CellSize / 2f - WorldMapLayout.HalfWidth) / 1000f,
(gridH * CellSize / 2f - WorldMapLayout.HalfHeight) / 1000f, 0f);
// Объекты видны -- туман между землёй (-10) и объектами (0+). Скрыты -- поверх объектов, под войсками (30)
sr.sortingOrder = IntelligenceConfig.Load().hideUnexploredObjects ? 20 : -9;
// 1 пиксель = 1 клетка карты; текстура натянута на всю землю (UV 0..1 -- от левого нижнего угла карты)
if (ground != null) ground.sharedMaterial.mainTexture = fog;
RefreshFog();
}
@@ -89,7 +86,9 @@ public class IntelligenceOverlay : MonoBehaviour
int y = cy * CellSize + CellSize / 2 - WorldMapLayout.HalfHeight;
MapVisibility v = intel.GetVisibility(x, y);
byte a = v == MapVisibility.Observed ? (byte)0 : v == MapVisibility.Explored ? explored : unexplored;
pixels[cy * gridW + cx] = new Color32(10, 14, 20, a);
Color c = Color.Lerp(GroundColor, FogColor, a / 255f); // туман -- в цвете земли
c.a = 1f;
pixels[cy * gridW + cx] = c;
}
fog.SetPixels32(pixels);
fog.Apply(false);
@@ -107,9 +106,9 @@ public class IntelligenceOverlay : MonoBehaviour
m = CreateMarker(c.ArmyId);
markers[c.ArmyId] = m;
}
m.sprite.transform.position = new Vector3(c.X / 1000f, c.Y / 1000f, 0f);
m.label.transform.position = m.sprite.transform.position + new Vector3(0, 0.75f, 0);
m.sprite.color = c.Observed ? ObservedColor : GhostColor;
m.sprite.transform.position = new Vector3(c.X / 1000f, MarkerHeight / 2f, c.Y / 1000f);
m.label.transform.position = new Vector3(c.X / 1000f, MarkerHeight + 0.8f, c.Y / 1000f);
m.sprite.sharedMaterial = Map3D.Mat(c.Observed ? ObservedColor : GhostColor);
string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия";
m.label.text = c.Observed
@@ -128,24 +127,13 @@ public class IntelligenceOverlay : MonoBehaviour
}
}
(SpriteRenderer, TextMeshPro) CreateMarker(string armyId)
(Renderer, TextMeshPro) CreateMarker(string armyId)
{
var go = new GameObject("Contact_" + armyId);
go.transform.SetParent(mapRoot, false);
go.transform.localScale = Vector3.one * MarkerSize;
go.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб-флажок: не путается со своими квадратиками
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.sortingOrder = 32;
var lgo = new GameObject("ContactLabel_" + armyId);
lgo.transform.SetParent(mapRoot, false);
var label = lgo.AddComponent<TextMeshPro>();
label.fontSize = 2.6f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
label.sortingOrder = 33;
return (sr, label);
var r = Map3D.Block(mapRoot, "Contact_" + armyId, PrimitiveType.Cube, ObservedColor, Vector3.zero, Vector2.one * MarkerSize, MarkerHeight);
r.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб-флажок: не путается со своими кубиками
var label = Map3D.Label(mapRoot, "", Vector3.zero, 2.6f);
label.gameObject.name = "ContactLabel_" + armyId;
return (r, label);
}
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
// 3D НА ПРИМИТИВАХ: общие помощники для карты мира и 3D-поселения (переход на 2.5D, 04.11.2026).
// Материал берётся у встроенного примитива (шейдер URP по умолчанию точно есть в сборке), цвета кешируются.
// Подписи (TextMeshPro) поворачиваются к камере (FaceCamera).
public static class Map3D
{
static Material baseMaterial;
static readonly Dictionary<Color, Material> materials = new Dictionary<Color, Material>();
public static Material Mat(Color color)
{
if (baseMaterial == null)
{
var probe = GameObject.CreatePrimitive(PrimitiveType.Cube);
baseMaterial = new Material(probe.GetComponent<Renderer>().sharedMaterial);
Object.Destroy(probe);
materials.Clear();
}
if (!materials.TryGetValue(color, out var m) || m == null)
{
m = new Material(baseMaterial) { color = color };
materials[color] = m;
}
return m;
}
// Объект на земле: основание size.x * size.y (по X и Z), высота height, низ -- на уровне ground.y
public static Renderer Block(Transform parent, string name, PrimitiveType type, Color color, Vector3 ground, Vector2 size, float height,
bool collider = false)
{
var go = GameObject.CreatePrimitive(type);
go.name = name;
if (!collider) Object.Destroy(go.GetComponent<Collider>());
go.transform.SetParent(parent, false);
// У цилиндра высота -- 2 единицы при масштабе 1
float scaleY = type == PrimitiveType.Cylinder || type == PrimitiveType.Capsule ? height / 2f : height;
go.transform.localScale = new Vector3(size.x, scaleY, size.y);
go.transform.position = ground + Vector3.up * height / 2f;
var r = go.GetComponent<Renderer>();
r.sharedMaterial = Mat(color);
return r;
}
// Подпись над объектом, всегда лицом к камере
public static TextMeshPro Label(Transform parent, string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(parent, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.6f);
tmp.color = Color.white;
tmp.outlineWidth = 0.25f;
tmp.outlineColor = Color.black;
go.AddComponent<FaceCamera>();
return tmp;
}
// Камере -- обычный 3D-рендерер URP (второй в списке; добавляет Tools -> Bogatyri -> 3D-город).
// Нет его -- остаётся рендерер по умолчанию, и в лог пишется, что делать.
public static void UseRenderer3D(Camera cam)
{
var pipeline = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
var data = cam != null ? cam.GetUniversalAdditionalCameraData() : null;
if (pipeline == null || data == null) return;
var list = pipeline.rendererDataList;
for (int i = 0; i < list.Length; i++)
if (list[i] is UniversalRendererData) { data.SetRenderer(i); return; }
Debug.LogWarning("[3D] В URP нет обычного 3D-рендерера: выполните Tools -> Bogatyri -> 3D-город: создать сцену City3D (он добавится). Без него 3D рисуется без света.");
}
// Солнце и общий свет, если в сцене их нет (2D-сцены света не имели)
public static void EnsureSun()
{
foreach (var l in Object.FindObjectsByType<Light>(FindObjectsSortMode.None))
if (l.type == LightType.Directional) return;
var sunGo = new GameObject("Sun");
var sun = sunGo.AddComponent<Light>();
sun.type = LightType.Directional;
sun.intensity = 1.15f;
sun.shadows = LightShadows.Soft;
sunGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f);
RenderSettings.ambientMode = AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.55f, 0.58f, 0.62f);
}
}
// Подпись поворачивается к камере (наклонный вид 2.5D)
public class FaceCamera : MonoBehaviour
{
static Camera cam;
void LateUpdate()
{
if (cam == null) cam = Camera.main;
if (cam != null) transform.rotation = cam.transform.rotation;
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c36fcaa4cddf4c76ad06c759afc8301e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+46 -22
View File
@@ -1,15 +1,22 @@
using UnityEngine;
using UnityEngine.EventSystems;
// Камера карты мира: перетаскивание (палец/мышь), зум (два пальца/колёсико), границы карты.
// Камера карты мира, 2.5D (переход на 3D, 04.11.2026): наклонный вид под фиксированным углом, без вращения.
// Перетаскивание (палец/мышь) двигает карту, два пальца/колёсико -- приближение, границы карты.
// Отличает "тап" от "перетаскивания": тап -- это касание, при котором палец почти не сдвинулся.
// Карта лежит на земле: координаты карты (x, y) -> мир (x, 0, y).
[DefaultExecutionOrder(-10)] // считать тап раньше, чем его прочитает WorldMapController
[RequireComponent(typeof(Camera))]
public class MapCameraController : MonoBehaviour
{
[Header("Вид")]
public float pitch = 55f; // наклон, градусы от горизонта
public float yaw = 0f; // север карты -- вверху экрана, как было в 2D
public float distance = 18f;
[Header("Зум")]
public float minOrthoSize = 4f;
public float maxOrthoSize = 16f;
public float minDistance = 7f;
public float maxDistance = 42f;
public float mouseZoomSpeed = 2f;
[Header("Тап")]
@@ -20,6 +27,7 @@ public class MapCameraController : MonoBehaviour
[HideInInspector] public Vector2 mapHalfSize = new Vector2(20f, 14f);
private Camera cam;
private Vector3 target; // точка на земле, на которую смотрит камера
private Vector3 lastPointerPosition;
private Vector3 pressPosition;
private bool isPressed;
@@ -31,9 +39,15 @@ public class MapCameraController : MonoBehaviour
void Awake()
{
cam = GetComponent<Camera>();
cam.orthographic = false;
cam.fieldOfView = 40f;
cam.nearClipPlane = 0.3f;
cam.farClipPlane = 300f;
Map3D.UseRenderer3D(cam);
Apply();
}
// Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание)
// Было ли за этот кадр нажатие по карте (не по UI и не перетаскивание). Точка -- на земле (x, 0, z)
public bool TryGetTap(out Vector3 worldPosition)
{
worldPosition = tapWorldPosition;
@@ -42,8 +56,9 @@ public class MapCameraController : MonoBehaviour
public void CenterOn(Vector3 worldPosition)
{
transform.position = new Vector3(worldPosition.x, worldPosition.y, transform.position.z);
target = new Vector3(worldPosition.x, 0f, worldPosition.z);
ClampToMap();
Apply();
}
void Update()
@@ -54,12 +69,14 @@ public class MapCameraController : MonoBehaviour
{
HandlePinch();
isPressed = false; // после щипка не засчитываем отпускание как тап
Apply();
return;
}
HandlePointer();
HandleMouseWheel();
ClampToMap();
Apply();
}
void HandlePointer()
@@ -86,10 +103,10 @@ public class MapCameraController : MonoBehaviour
if (isDragging)
{
// Сдвигаем камеру ровно на столько, на сколько сдвинулся палец в мире -- карта "прилипает" к пальцу
Vector3 previous = cam.ScreenToWorldPoint(lastPointerPosition);
Vector3 current = cam.ScreenToWorldPoint(Input.mousePosition);
transform.position += previous - current;
// Сдвигаем камеру ровно на столько, на сколько сдвинулся палец по земле -- карта "прилипает" к пальцу
target += GroundPoint(lastPointerPosition) - GroundPoint(Input.mousePosition);
ClampToMap();
Apply();
}
lastPointerPosition = Input.mousePosition;
}
@@ -99,8 +116,7 @@ public class MapCameraController : MonoBehaviour
if (!isDragging)
{
hasTap = true;
tapWorldPosition = cam.ScreenToWorldPoint(Input.mousePosition);
tapWorldPosition.z = 0f;
tapWorldPosition = GroundPoint(Input.mousePosition);
}
isPressed = false;
isDragging = false;
@@ -116,7 +132,7 @@ public class MapCameraController : MonoBehaviour
float currentDistance = (a.position - b.position).magnitude;
if (currentDistance <= 0.01f) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize * previousDistance / currentDistance, minOrthoSize, maxOrthoSize);
distance = Mathf.Clamp(distance * previousDistance / currentDistance, minDistance, maxDistance);
ClampToMap();
}
@@ -124,22 +140,30 @@ public class MapCameraController : MonoBehaviour
{
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll == 0f || IsPointerOverUI()) return;
cam.orthographicSize = Mathf.Clamp(cam.orthographicSize - scroll * mouseZoomSpeed * 5f, minOrthoSize, maxOrthoSize);
distance = Mathf.Clamp(distance - scroll * mouseZoomSpeed * 10f, minDistance, maxDistance);
}
// Камера не уезжает за край карты (если карта меньше экрана -- центрируем)
// Точка, на которую смотрит камера, не уходит за край карты
void ClampToMap()
{
float halfViewHeight = cam.orthographicSize;
float halfViewWidth = halfViewHeight * cam.aspect;
target.x = Mathf.Clamp(target.x, -mapHalfSize.x, mapHalfSize.x);
target.z = Mathf.Clamp(target.z, -mapHalfSize.y, mapHalfSize.y);
target.y = 0f;
}
float maxX = Mathf.Max(0f, mapHalfSize.x - halfViewWidth);
float maxY = Mathf.Max(0f, mapHalfSize.y - halfViewHeight);
void Apply()
{
Quaternion rot = Quaternion.Euler(pitch, yaw, 0);
transform.rotation = rot;
transform.position = target - rot * Vector3.forward * distance;
}
Vector3 p = transform.position;
p.x = Mathf.Clamp(p.x, -maxX, maxX);
p.y = Mathf.Clamp(p.y, -maxY, maxY);
transform.position = p;
// Точка на земле (y = 0) под экранной точкой
Vector3 GroundPoint(Vector3 screen)
{
Ray ray = cam.ScreenPointToRay(screen);
var plane = new Plane(Vector3.up, Vector3.zero);
return plane.Raycast(ray, out float t) ? ray.GetPoint(t) : target;
}
static bool IsPointerOverUI()
+25 -23
View File
@@ -2,7 +2,7 @@ using Bogatyri.World;
using TMPro;
using UnityEngine;
// Войско в походе на карте: группа маленьких квадратиков + подпись с числом солдат и таймером.
// Войско в походе на карте: группа маленьких кубиков на земле + подпись с числом солдат и таймером (2.5D, 04.11.2026).
// Сам ничего не решает -- позицию и состояние берёт из WorldSimulation.
public class MarchView : MonoBehaviour
{
@@ -16,7 +16,7 @@ public class MarchView : MonoBehaviour
public int MarchId { get; private set; }
private SpriteRenderer[] squares;
private Renderer[] squares;
private TextMeshPro label;
private GameObject routeLine;
private MarchState shownState;
@@ -36,12 +36,15 @@ public class MarchView : MonoBehaviour
{
var go = new GameObject("Label");
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(0, 0.75f, 0);
go.transform.localPosition = new Vector3(0, 1.0f, 0);
label = go.AddComponent<TextMeshPro>();
label.fontSize = 3f;
label.alignment = TextAlignmentOptions.Center;
label.rectTransform.sizeDelta = new Vector2(4f, 0.8f);
label.sortingOrder = 31;
label.rectTransform.sizeDelta = new Vector2(5f, 0.9f);
label.color = Color.white;
label.outlineWidth = 0.25f;
label.outlineColor = Color.black;
go.AddComponent<FaceCamera>();
}
// Перестраиваем квадратики, только когда поменялось число солдат (после боя)
@@ -54,7 +57,7 @@ public class MarchView : MonoBehaviour
int count = Mathf.Clamp(soldiers, 1, MaxSquares);
int rows = Mathf.CeilToInt(count / (float)SquaresPerRow);
squares = new SpriteRenderer[count];
squares = new Renderer[count];
for (int i = 0; i < count; i++)
{
@@ -62,19 +65,19 @@ public class MarchView : MonoBehaviour
int row = i / SquaresPerRow;
int inThisRow = Mathf.Min(SquaresPerRow, count - row * SquaresPerRow);
var go = new GameObject("Soldier");
var go = GameObject.CreatePrimitive(PrimitiveType.Cube);
go.name = "Soldier";
Destroy(go.GetComponent<Collider>());
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(
(col - (inThisRow - 1) / 2f) * SquareSpacing,
((rows - 1) / 2f - row) * SquareSpacing,
0f);
SquareSize / 2f,
((rows - 1) / 2f - row) * SquareSpacing);
go.transform.localScale = Vector3.one * SquareSize;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = color;
sr.sortingOrder = 30;
squares[i] = sr;
var r = go.GetComponent<Renderer>();
r.sharedMaterial = Map3D.Mat(color);
squares[i] = r;
}
}
@@ -85,16 +88,15 @@ public class MarchView : MonoBehaviour
Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 delta = target - home;
routeLine = new GameObject($"Route_{march.id}");
// Тонкая полоса на земле от города до цели
routeLine = GameObject.CreatePrimitive(PrimitiveType.Cube);
routeLine.name = $"Route_{march.id}";
Destroy(routeLine.GetComponent<Collider>());
routeLine.transform.SetParent(transform.parent, false);
routeLine.transform.position = (home + target) / 2f;
routeLine.transform.localScale = new Vector3(delta.magnitude, 0.08f, 1f);
routeLine.transform.rotation = Quaternion.Euler(0, 0, Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg);
var sr = routeLine.AddComponent<SpriteRenderer>();
sr.sprite = PrimitiveSprites.Square;
sr.color = new Color(1f, 1f, 1f, 0.35f);
sr.sortingOrder = -5;
routeLine.transform.position = (home + target) / 2f + Vector3.up * 0.02f;
routeLine.transform.localScale = new Vector3(delta.magnitude, 0.02f, 0.08f);
routeLine.transform.rotation = Quaternion.Euler(0, -Mathf.Atan2(delta.z, delta.x) * Mathf.Rad2Deg, 0);
routeLine.GetComponent<Renderer>().sharedMaterial = Map3D.Mat(new Color(0.85f, 0.85f, 0.8f));
}
void OnDestroy()
+52 -54
View File
@@ -48,7 +48,10 @@ public class WorldMapController : MonoBehaviour
const float CastleSize = 2.2f;
const float CitySize = 1.1f;
const float CampSize = 0.8f;
const float StartZoom = 8f;
const float StartDistance = 18f; // камера 2.5D: расстояние до земли
// Высота объектов над землёй (2.5D на примитивах, 04.11.2026)
const float CastleHeight = 1.4f, CityHeight = 0.9f, CampHeight = 0.5f, PointHeight = 0.45f;
static readonly Color GroundColor = new Color(0.3f, 0.42f, 0.25f);
static readonly Color CastleColor = new Color(0.55f, 0.15f, 0.15f);
@@ -67,10 +70,10 @@ public class WorldMapController : MonoBehaviour
private readonly List<(MapNode node, TextMeshPro label)> garrisonLabels = new List<(MapNode, TextMeshPro)>();
// Лагеря меняют вид: разбит -- серый, с таймером до появления
private readonly List<(MapNode node, SpriteRenderer sprite, TextMeshPro label)> campViews = new List<(MapNode, SpriteRenderer, TextMeshPro)>();
private readonly List<(MapNode node, Renderer sprite, TextMeshPro label)> campViews = new List<(MapNode, Renderer, TextMeshPro)>();
// Ресурсные точки появляются и исчезают -- виды создаются и убираются по ходу игры
private readonly Dictionary<string, (SpriteRenderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (SpriteRenderer, TextMeshPro)>();
private readonly Dictionary<string, (Renderer sprite, TextMeshPro label)> pointViews = new Dictionary<string, (Renderer, TextMeshPro)>();
private readonly List<string> stalePoints = new List<string>();
const float PointSize = 0.7f;
static readonly Color[] PointColors =
@@ -93,8 +96,8 @@ public class WorldMapController : MonoBehaviour
void Start()
{
mapRoot = new GameObject("Map").transform;
BuildMap();
IntelligenceOverlay.Create(mapRoot, World); // туман и чужие армии (модуль Разведки)
Renderer ground = BuildMap();
IntelligenceOverlay.Create(mapRoot, World, ground); // туман на земле и чужие армии (модуль Разведки)
SetUpCamera();
backButton.onClick.AddListener(SceneNavigator.ReturnToCity);
@@ -211,11 +214,13 @@ public class WorldMapController : MonoBehaviour
// ---------------- КАРТА ----------------
void BuildMap()
// Карта лежит на земле (2.5D): замок -- стена с башней, свой город -- кубик на белой площадке,
// соседи -- кубики, лагеря -- цилиндры, ресурсные точки -- ромбы. Подписи висят над объектами.
Renderer BuildMap()
{
float width = WorldMapLayout.HalfWidth / 1000f * 2f;
float height = WorldMapLayout.HalfHeight / 1000f * 2f;
CreateSprite("Ground", PrimitiveSprites.Square, GroundColor, Vector3.zero, new Vector3(width, height, 1f), -10);
Renderer ground = CreateGround(width, height);
foreach (var node in World.Nodes)
{
@@ -224,32 +229,50 @@ public class WorldMapController : MonoBehaviour
switch (node.type)
{
case MapNodeType.BossCastle:
CreateSprite(node.id, PrimitiveSprites.Square, CastleColor, pos, Vector3.one * CastleSize, 0);
CreateSprite(node.id + "_Keep", PrimitiveSprites.Square, new Color(0.3f, 0.08f, 0.08f), pos, Vector3.one * CastleSize * 0.5f, 1);
CreateLabel(node.displayName, pos + new Vector3(0, CastleSize / 2f + 0.4f, 0), 4f);
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CastleSize / 2f + 0.35f, 0), 3f)));
Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, CastleColor, pos, Vector2.one * CastleSize, CastleHeight * 0.6f);
Map3D.Block(mapRoot, node.id + "_Keep", PrimitiveType.Cube, new Color(0.3f, 0.08f, 0.08f), pos, Vector2.one * CastleSize * 0.5f, CastleHeight * 1.8f);
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CastleHeight * 1.8f + 1.1f), 4f);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CastleHeight * 1.8f + 0.45f), 3f)));
break;
case MapNodeType.PlayerCity:
// Белая рамка -- чтобы свой город сразу бросался в глаза
CreateSprite(node.id + "_Frame", PrimitiveSprites.Square, Color.white, pos, Vector3.one * (CitySize + 0.25f), 0);
CreateSprite(node.id, PrimitiveSprites.Square, PlayerCityColor, pos, Vector3.one * CitySize, 1);
CreateLabel(node.displayName, pos - new Vector3(0, CitySize / 2f + 0.35f, 0), 3.2f);
// Белая площадка -- чтобы свой город сразу бросался в глаза
Map3D.Block(mapRoot, node.id + "_Frame", PrimitiveType.Cube, Color.white, pos, Vector2.one * (CitySize + 0.35f), 0.12f);
Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, PlayerCityColor, pos + Vector3.up * 0.12f, Vector2.one * CitySize, CityHeight);
Map3D.Label(mapRoot, node.displayName, pos + Vector3.up * (CityHeight + 0.75f), 3.2f);
break;
case MapNodeType.NeighborCity:
CreateSprite(node.id, PrimitiveSprites.Square, NeighborCityColor, pos, Vector3.one * CitySize, 0);
garrisonLabels.Add((node, CreateLabel("", pos - new Vector3(0, CitySize / 2f + 0.5f, 0), 2.6f)));
Map3D.Block(mapRoot, node.id, PrimitiveType.Cube, NeighborCityColor, pos, Vector2.one * CitySize, CityHeight);
garrisonLabels.Add((node, Map3D.Label(mapRoot, "", pos + Vector3.up * (CityHeight + 0.85f), 2.6f)));
break;
case MapNodeType.Camp:
// Круг, а не квадрат: лагерь не путается с городом
var sprite = CreateSprite(node.id, PrimitiveSprites.Circle, CampColor, pos, Vector3.one * CampSize, 0);
var label = CreateLabel("", pos - new Vector3(0, CampSize / 2f + 0.5f, 0), 2.3f);
// Цилиндр, а не куб: лагерь не путается с городом
var sprite = Map3D.Block(mapRoot, node.id, PrimitiveType.Cylinder, CampColor, pos, Vector2.one * CampSize, CampHeight);
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (CampHeight + 0.8f), 2.3f);
campViews.Add((node, sprite, label));
break;
}
}
return ground;
}
// Земля -- плоскость с текстурой: её раскрашивает туман (IntelligenceOverlay). UV (0,0) -- левый нижний угол карты
Renderer CreateGround(float width, float height)
{
var mesh = new Mesh { name = "WorldMap_Ground" };
float hx = width / 2f, hz = height / 2f;
mesh.vertices = new[] { new Vector3(-hx, 0, -hz), new Vector3(-hx, 0, hz), new Vector3(hx, 0, hz), new Vector3(hx, 0, -hz) };
mesh.uv = new[] { new Vector2(0, 0), new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0) };
mesh.triangles = new[] { 0, 1, 2, 0, 2, 3 };
mesh.RecalculateNormals();
var go = new GameObject("Ground");
go.transform.SetParent(mapRoot, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
var r = go.AddComponent<MeshRenderer>();
r.sharedMaterial = new Material(Map3D.Mat(GroundColor)) { color = Color.white };
return r;
}
void SetUpCamera()
@@ -263,7 +286,8 @@ public class WorldMapController : MonoBehaviour
}
mapCamera.mapHalfSize = new Vector2(WorldMapLayout.HalfWidth / 1000f, WorldMapLayout.HalfHeight / 1000f);
mapCamera.GetComponent<Camera>().orthographicSize = StartZoom;
mapCamera.distance = StartDistance;
Map3D.EnsureSun(); // в 2D-сцене света не было
CenterOnMyCity();
}
@@ -273,33 +297,6 @@ public class WorldMapController : MonoBehaviour
if (home != null) mapCamera.CenterOn(ToWorld(home));
}
SpriteRenderer CreateSprite(string name, Sprite sprite, Color color, Vector3 position, Vector3 scale, int order)
{
var go = new GameObject(name);
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
go.transform.localScale = scale;
var sr = go.AddComponent<SpriteRenderer>();
sr.sprite = sprite;
sr.color = color;
sr.sortingOrder = order;
return sr;
}
TextMeshPro CreateLabel(string text, Vector3 position, float fontSize)
{
var go = new GameObject("Label");
go.transform.SetParent(mapRoot, false);
go.transform.position = position;
var tmp = go.AddComponent<TextMeshPro>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.alignment = TextAlignmentOptions.Center;
tmp.rectTransform.sizeDelta = new Vector2(6f, 1.2f);
tmp.sortingOrder = 10;
return tmp;
}
void RefreshCamps()
{
if (Time.unscaledTime < nextCampRefresh) return;
@@ -315,7 +312,7 @@ public class WorldMapController : MonoBehaviour
foreach (var (node, sprite, label) in campViews)
{
long left = World.CampRespawnInMs(node.id, now);
sprite.color = left > 0 ? CampClearedColor : CampColor;
sprite.sharedMaterial = Map3D.Mat(left > 0 ? CampClearedColor : CampColor);
label.text = left > 0 // разбил его сам игрок -- это он знает
? $"{node.displayName}\n<color=#BBBBBB>разбит, вернётся через {FormatMinutes(left)}</color>"
: $"{node.displayName}, ур. {node.level}\n{GarrisonText(node)}";
@@ -332,9 +329,9 @@ public class WorldMapController : MonoBehaviour
{
MapNode node = World.GetNode(p.id);
Vector3 pos = ToWorld(node);
var sprite = CreateSprite(p.id, PrimitiveSprites.Square, PointColors[p.kind], pos, Vector3.one * PointSize, 0);
sprite.transform.rotation = Quaternion.Euler(0, 0, 45); // ромб -- не путается с городом и лагерем
var label = CreateLabel("", pos - new Vector3(0, PointSize / 2f + 0.5f, 0), 2.2f);
var sprite = Map3D.Block(mapRoot, p.id, PrimitiveType.Cube, PointColors[p.kind], pos, Vector2.one * PointSize, PointHeight);
sprite.transform.rotation = Quaternion.Euler(0, 45, 0); // ромб -- не путается с городом и лагерем
var label = Map3D.Label(mapRoot, "", pos + Vector3.up * (PointHeight + 0.75f), 2.2f);
view = (sprite, label);
pointViews[p.id] = view;
}
@@ -384,9 +381,10 @@ public class WorldMapController : MonoBehaviour
return sec >= 60 ? $"{(sec + 59) / 60} мин" : $"{sec} сек";
}
// Координаты карты (x, y) -> мир на земле (x, 0, y). 1000 милли-единиц = 1 клетка
public static Vector3 ToWorld(MapNode node)
{
return new Vector3(node.x / 1000f, node.y / 1000f, 0f);
return new Vector3(node.x / 1000f, 0f, node.y / 1000f);
}
// ---------------- ВЫБОР ЦЕЛИ ----------------
@@ -419,7 +417,7 @@ public class WorldMapController : MonoBehaviour
: node.type == MapNodeType.ResourcePoint ? PointSize : CitySize;
float halfSize = size / 2f + 0.2f;
Vector3 pos = ToWorld(node);
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.y - pos.y) <= halfSize) return node;
if (Mathf.Abs(world.x - pos.x) <= halfSize && Mathf.Abs(world.z - pos.z) <= halfSize) return node;
}
return null;
}
+1
View File
@@ -110,6 +110,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
| 01.11.2026 | Claude | Решения 31.10, п. 1: объекты видны всегда, а гарнизоны, запас и срок точек, прогноз боя и сбора — только в зоне обзора (`WorldMapController`). |
| 31.10.2026 | Claude | Исправлено: после перезапуска или перемотки обратный путь войска не открывал туман (до расчёта боя Разведка считала, что войско стоит у цели). Мир досчитывается по событиям (`WorldSimulation.AdvanceInSteps`), Разведка — до каждого из них. Туман темнее. |
+1
View File
@@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. |
| 04.11.2026 | Claude | 3D-поселение, этап I — прототип на примитивах (ТЗ от 04.11). Обновлён Settlement. |
| 04.11.2026 | Claude | Решения по отчёту Саши от 01.11: потери сильной стороны до 2% при 10:1, 5 временных мест госпиталя, цена лечения 1 / 1 / 2 / 3 золота. Обновлены Battle, Army. |
| 01.11.2026 | Claude | Здание «Госпиталь» (решения 31.10, п. 7). Обновлены Army, Core, Settlement. |
+1
View File
@@ -158,6 +158,7 @@
| Дата | Кто | Что |
| --- | --- | --- |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. |
| 01.11.2026 | Claude | Решения 31.10, п. 2: поход «Разведка» (`IWorld.TryStartScout`, `MarchData.scout`, `WorldEventType.ScoutArrived`), кнопка в окне атаки, тексты в окне армий, на отряде и в окне возвращения. |
| 01.11.2026 | Claude | Решения 31.10, п. 3: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |