diff --git a/Assets/Scripts/City3D/City3DController.cs b/Assets/Scripts/City3D/City3DController.cs index c850503..00b09d2 100644 --- a/Assets/Scripts/City3D/City3DController.cs +++ b/Assets/Scripts/City3D/City3DController.cs @@ -82,6 +82,7 @@ public class City3DController : MonoBehaviour void Start() { cam = Camera.main; + Map3D.UseRenderer3D(cam); // обычный 3D-рендерер URP (на случай, если сцену собрали вручную) cameraRig = cam.GetComponent() ?? cam.gameObject.AddComponent(); cameraRig.bounds = new Rect(2, 2, City3DLayout.Size - 4, City3DLayout.Size - 4); cameraRig.target = new Vector3(City3DLayout.Size / 2f, 0, City3DLayout.Size / 2f); diff --git a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs index a7900af..8c5de56 100644 --- a/Assets/Scripts/WorldMap/IntelligenceOverlay.cs +++ b/Assets/Scripts/WorldMap/IntelligenceOverlay.cs @@ -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 markers = new Dictionary(); + private Renderer ground; + private readonly Dictionary markers = new Dictionary(); private readonly HashSet alive = new HashSet(); private readonly List stale = new List(); - 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(); 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(); - // 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(); - sr.sprite = PrimitiveSprites.Square; - sr.sortingOrder = 32; - - var lgo = new GameObject("ContactLabel_" + armyId); - lgo.transform.SetParent(mapRoot, false); - var label = lgo.AddComponent(); - 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» -> «Велимир») diff --git a/Assets/Scripts/WorldMap/Map3D.cs b/Assets/Scripts/WorldMap/Map3D.cs new file mode 100644 index 0000000..e1640bf --- /dev/null +++ b/Assets/Scripts/WorldMap/Map3D.cs @@ -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 materials = new Dictionary(); + + public static Material Mat(Color color) + { + if (baseMaterial == null) + { + var probe = GameObject.CreatePrimitive(PrimitiveType.Cube); + baseMaterial = new Material(probe.GetComponent().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()); + 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(); + 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(); + 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(); + 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(FindObjectsSortMode.None)) + if (l.type == LightType.Directional) return; + var sunGo = new GameObject("Sun"); + var sun = sunGo.AddComponent(); + 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; + } +} diff --git a/Assets/Scripts/WorldMap/Map3D.cs.meta b/Assets/Scripts/WorldMap/Map3D.cs.meta new file mode 100644 index 0000000..b3a901d --- /dev/null +++ b/Assets/Scripts/WorldMap/Map3D.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c36fcaa4cddf4c76ad06c759afc8301e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/WorldMap/MapCameraController.cs b/Assets/Scripts/WorldMap/MapCameraController.cs index 31fcc64..c4079ac 100644 --- a/Assets/Scripts/WorldMap/MapCameraController.cs +++ b/Assets/Scripts/WorldMap/MapCameraController.cs @@ -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(); + 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() diff --git a/Assets/Scripts/WorldMap/MarchView.cs b/Assets/Scripts/WorldMap/MarchView.cs index bd20f7c..a0f22af 100644 --- a/Assets/Scripts/WorldMap/MarchView.cs +++ b/Assets/Scripts/WorldMap/MarchView.cs @@ -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(); 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(); } // Перестраиваем квадратики, только когда поменялось число солдат (после боя) @@ -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()); 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(); - sr.sprite = PrimitiveSprites.Square; - sr.color = color; - sr.sortingOrder = 30; - squares[i] = sr; + var r = go.GetComponent(); + 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()); 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(); - 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().sharedMaterial = Map3D.Mat(new Color(0.85f, 0.85f, 0.8f)); } void OnDestroy() diff --git a/Assets/Scripts/WorldMap/WorldMapController.cs b/Assets/Scripts/WorldMap/WorldMapController.cs index a4e47dc..da02881 100644 --- a/Assets/Scripts/WorldMap/WorldMapController.cs +++ b/Assets/Scripts/WorldMap/WorldMapController.cs @@ -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 pointViews = new Dictionary(); + private readonly Dictionary pointViews = new Dictionary(); private readonly List stalePoints = new List(); 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().sharedMesh = mesh; + var r = go.AddComponent(); + 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().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(); - 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(); - 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разбит, вернётся через {FormatMinutes(left)}" : $"{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; } diff --git a/Docs/Modules/Intelligence.md b/Docs/Modules/Intelligence.md index 573d0a5..54ade32 100644 --- a/Docs/Modules/Intelligence.md +++ b/Docs/Modules/Intelligence.md @@ -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`), Разведка — до каждого из них. Туман темнее. | diff --git a/Docs/Modules/README.md b/Docs/Modules/README.md index 0a508fa..909d336 100644 --- a/Docs/Modules/README.md +++ b/Docs/Modules/README.md @@ -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. | diff --git a/Docs/Modules/World.md b/Docs/Modules/World.md index 6ba116e..75898e6 100644 --- a/Docs/Modules/World.md +++ b/Docs/Modules/World.md @@ -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: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |