Feature/3d hud #18
@@ -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);
|
||||
|
||||
@@ -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» -> «Велимир»)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c36fcaa4cddf4c76ad06c759afc8301e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`), Разведка — до каждого из них. Туман темнее. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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: армии в пути проходят друг через друга — правило записано, тест на одну точку в один момент. |
|
||||
|
||||
Reference in New Issue
Block a user