- Новый модуль Bogatyri.Intelligence (без Unity): туман в трёх состояниях, обзор поселения 5 / армии 1 клетка, «Обзор» +10/20/30% (заменяет), примерная численность ±20%, опознание 50% на событие наблюдения, последний контакт 10 реальных минут, офлайн-досчёт шагами 10 с, сохранение в секции intelligence. - Мир: IWorldMap (армии в момент времени), WorldAdvancingEvent, патрули-заглушки соседей (временно, выключаются в MarchConfig). - Карта: IntelligenceOverlay — туман и метки чужих армий. - Таблица IntelligenceConfig, 11 новых тестов, документы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
7.3 KiB
C#
165 lines
7.3 KiB
C#
using System.Collections.Generic;
|
||
using Bogatyri.World;
|
||
using TMPro;
|
||
using UnityEngine;
|
||
|
||
// Разведка на карте мира (М1): туман войны и чужие армии. Правил тут нет -- всё знает модуль Разведки (IIntelligence),
|
||
// здесь только рисуем то, что он вернул.
|
||
//
|
||
// Туман: неисследованное -- тёмное, исследованное -- притушено, в зоне обзора -- чисто.
|
||
// По умолчанию туман только затемняет землю: города, лагеря и точки видны (IntelligenceConfig.hideUnexploredObjects).
|
||
// Чужие армии -- только те, что знает Разведка: в обзоре -- красные, с примерной численностью «~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;
|
||
const int CellSize = 1000;
|
||
|
||
private Transform mapRoot;
|
||
private WorldSimulation world;
|
||
private Texture2D fog;
|
||
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 readonly HashSet<string> alive = new HashSet<string>();
|
||
private readonly List<string> stale = new List<string>();
|
||
|
||
public static IntelligenceOverlay Create(Transform mapRoot, WorldSimulation world)
|
||
{
|
||
var go = new GameObject("Intelligence");
|
||
go.transform.SetParent(mapRoot, false);
|
||
var overlay = go.AddComponent<IntelligenceOverlay>();
|
||
overlay.mapRoot = mapRoot;
|
||
overlay.world = world;
|
||
overlay.CreateFog();
|
||
return overlay;
|
||
}
|
||
|
||
void CreateFog()
|
||
{
|
||
gridW = (2 * WorldMapLayout.HalfWidth + CellSize - 1) / CellSize;
|
||
gridH = (2 * WorldMapLayout.HalfHeight + CellSize - 1) / CellSize;
|
||
fog = new Texture2D(gridW, gridH, TextureFormat.RGBA32, false)
|
||
{
|
||
filterMode = FilterMode.Bilinear, // мягкий край тумана
|
||
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;
|
||
RefreshFog();
|
||
}
|
||
|
||
void OnDestroy()
|
||
{
|
||
if (fog != null) Destroy(fog);
|
||
}
|
||
|
||
void Update()
|
||
{
|
||
if (!GameServices.TryGet(out IIntelligence intel)) return;
|
||
if (Time.unscaledTime >= nextFogRefresh)
|
||
{
|
||
nextFogRefresh = Time.unscaledTime + 0.5f;
|
||
RefreshFog();
|
||
}
|
||
RefreshContacts(intel);
|
||
}
|
||
|
||
void RefreshFog()
|
||
{
|
||
if (!GameServices.TryGet(out IIntelligence intel)) return;
|
||
bool hide = IntelligenceConfig.Load().hideUnexploredObjects;
|
||
byte unexplored = (byte)(hide ? 245 : 170), explored = 90;
|
||
for (int cy = 0; cy < gridH; cy++)
|
||
for (int cx = 0; cx < gridW; cx++)
|
||
{
|
||
int x = cx * CellSize + CellSize / 2 - WorldMapLayout.HalfWidth;
|
||
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);
|
||
}
|
||
fog.SetPixels32(pixels);
|
||
fog.Apply(false);
|
||
}
|
||
|
||
void RefreshContacts(IIntelligence intel)
|
||
{
|
||
long now = MarchManager.NowMs;
|
||
alive.Clear();
|
||
foreach (var c in intel.Contacts)
|
||
{
|
||
alive.Add(c.ArmyId);
|
||
if (!markers.TryGetValue(c.ArmyId, out var m))
|
||
{
|
||
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;
|
||
|
||
string owner = c.Identified ? OwnerName(c.OwnerId) : "чужая армия";
|
||
m.label.text = c.Observed
|
||
? $"{owner}\n~{c.ApproxMin}–{c.ApproxMax} воинов"
|
||
: $"<color=#BBBBBB>{owner}, ~{c.ApproxMin}–{c.ApproxMax}\nконтакт {Ago(now - c.LastSeenMs)} назад</color>";
|
||
}
|
||
|
||
// Метки армий, которых Разведка больше не знает (контакт устарел)
|
||
stale.Clear();
|
||
foreach (var id in markers.Keys) if (!alive.Contains(id)) stale.Add(id);
|
||
foreach (var id in stale)
|
||
{
|
||
Destroy(markers[id].sprite.gameObject);
|
||
Destroy(markers[id].label.gameObject);
|
||
markers.Remove(id);
|
||
}
|
||
}
|
||
|
||
(SpriteRenderer, 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);
|
||
}
|
||
|
||
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
|
||
string OwnerName(string ownerId)
|
||
{
|
||
foreach (var n in world.Nodes)
|
||
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
|
||
return ownerId ?? "?";
|
||
}
|
||
|
||
static string Ago(long ms)
|
||
{
|
||
long sec = System.Math.Max(0, ms) / 1000;
|
||
return sec >= 60 ? $"{sec / 60} мин" : $"{sec} сек";
|
||
}
|
||
}
|