Feature/hero 3d #21

Merged
Alexander merged 3 commits from feature/hero-3d into develop 2026-11-06 19:00:32 +03:00
19 changed files with 723 additions and 28 deletions
+92 -10
View File
@@ -408,17 +408,20 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(groundTexture); go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(groundTexture);
go.AddComponent<MeshCollider>().sharedMesh = mesh; go.AddComponent<MeshCollider>().sharedMesh = mesh;
// Лес и луга за краем участка: камера не видит пустого неба // Местность за краем участка -- продолжение холмов, реки и гор (City3DLayout.SkirtHeight), дальше -- равнина
Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.24f, 0.4f, 0.22f), new Vector3(n / 2f, -0.7f, n / 2f), BuildSkirt();
new Vector2(n + 200f, n + 200f), 0.1f); Map3D.Block(transform, "OuterLand", PrimitiveType.Cube, new Color(0.3f, 0.5f, 0.26f),
new Vector3(n / 2f, City3DLayout.SkirtFloor - 0.05f, n / 2f), new Vector2(n + 2 * City3DLayout.SkirtCells + 300f, n + 2 * City3DLayout.SkirtCells + 300f), 0.1f);
var forest = new System.Random(7); var forest = new System.Random(7);
for (int i = 0; i < 140; i++) for (int i = 0; i < 260; i++)
{ {
// кольцо деревьев вокруг участка // лес вокруг участка: гуще у края, реже дальше; не в реке и не на горах
float angle = (float)forest.NextDouble() * Mathf.PI * 2f, r = n * 0.72f + (float)forest.NextDouble() * 14f; float angle = (float)forest.NextDouble() * Mathf.PI * 2f;
var p = new Vector3(n / 2f + Mathf.Cos(angle) * r, -0.6f, n / 2f + Mathf.Sin(angle) * r); float r = n * 0.6f + Mathf.Pow((float)forest.NextDouble(), 1.6f) * (City3DLayout.SkirtCells + 4f);
if (p.x > 0 && p.x < n && p.z > 0 && p.z < n) continue; // внутри участка -- свои деревья float x = n / 2f + Mathf.Cos(angle) * r, z = n / 2f + Mathf.Sin(angle) * r;
Map3D.Tree(transform, "ForestTree", p, 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6)); if (x > -0.5f && x < n + 0.5f && z > -0.5f && z < n + 0.5f) continue; // внутри участка -- свои деревья
if (City3DLayout.IsRiver(x, z) || City3DLayout.IsMountain(x, z)) continue;
Map3D.Tree(transform, "ForestTree", new Vector3(x, City3DLayout.SkirtHeight(x, z), z), 1.4f + (float)forest.NextDouble() * 0.8f, forest.Next(6));
} }
// Вода -- плоскость над руслом // Вода -- плоскость над руслом
@@ -427,10 +430,61 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
Destroy(water.GetComponent<Collider>()); Destroy(water.GetComponent<Collider>());
water.transform.SetParent(transform, false); water.transform.SetParent(transform, false);
water.transform.position = new Vector3(37f, -0.25f, n / 2f); water.transform.position = new Vector3(37f, -0.25f, n / 2f);
water.transform.localScale = new Vector3(9f, 0.05f, n); water.transform.localScale = new Vector3(9f, 0.05f, n + 2 * (City3DLayout.SkirtCells - 22)); // река течёт и за краем участка
water.GetComponent<Renderer>().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f)); water.GetComponent<Renderer>().sharedMaterial = Mat(new Color(0.3f, 0.58f, 0.85f));
} }
// Местность вокруг участка: сетка 1 клетка (на стыке -- те же вершины, что у земли участка, без щелей),
// без самого участка. Цвет -- тот же, что у земли (трава пятнами, берег, вода, горы со снегом), 1 пиксель на клетку
void BuildSkirt()
{
int n = City3DLayout.Size, s = City3DLayout.SkirtCells, w = n + 2 * s;
var verts = new Vector3[(w + 1) * (w + 1)];
var uvs = new Vector2[verts.Length];
for (int z = 0; z <= w; z++)
for (int x = 0; x <= w; x++)
{
float wx = x - s, wz = z - s;
int i = z * (w + 1) + x;
verts[i] = new Vector3(wx, City3DLayout.SkirtHeight(wx, wz), wz);
uvs[i] = new Vector2(x / (float)w, z / (float)w);
}
var tris = new List<int>(w * w * 6);
for (int z = 0; z < w; z++)
for (int x = 0; x < w; x++)
{
if (x >= s && x < s + n && z >= s && z < s + n) continue; // участок -- своя земля
int i = z * (w + 1) + x;
tris.Add(i); tris.Add(i + w + 1); tris.Add(i + 1);
tris.Add(i + 1); tris.Add(i + w + 1); tris.Add(i + w + 2);
}
var mesh = new Mesh { name = "City3D_Skirt", indexFormat = UnityEngine.Rendering.IndexFormat.UInt32 };
mesh.vertices = verts;
mesh.uv = uvs;
mesh.SetTriangles(tris, 0);
mesh.RecalculateNormals();
var tex = new Texture2D(w, w, TextureFormat.RGBA32, false) { filterMode = FilterMode.Bilinear, wrapMode = TextureWrapMode.Clamp };
var px = new Color32[w * w];
for (int z = 0; z < w; z++)
for (int x = 0; x < w; x++)
{
float wx = x - s + 0.5f, wz = z - s + 0.5f, h = City3DLayout.SkirtHeight(wx, wz);
Color c = City3DLayout.IsRiver(wx, wz) ? new Color(0.32f, 0.5f, 0.72f)
: City3DLayout.IsMountain(wx, wz) && h > 1.5f ? Color.Lerp(new Color(0.48f, 0.45f, 0.42f), new Color(0.85f, 0.86f, 0.88f), Mathf.Clamp01((h - 3.2f) / 1.5f))
: Mathf.Abs(wx - City3DLayout.RiverX(wz)) < 2.2f ? new Color(0.78f, 0.72f, 0.52f)
: Color.Lerp(new Color(0.33f, 0.56f, 0.27f), new Color(0.45f, 0.66f, 0.32f), Mathf.PerlinNoise(wx * 0.25f, wz * 0.25f));
px[z * w + x] = c;
}
tex.SetPixels32(px);
tex.Apply(false);
var go = new GameObject("Skirt");
go.transform.SetParent(transform, false);
go.AddComponent<MeshFilter>().sharedMesh = mesh;
go.AddComponent<MeshRenderer>().sharedMaterial = Map3D.TexturedMat(tex); // без коллайдера: тап и стройка -- только на участке
}
void BuildObstacles() void BuildObstacles()
{ {
int i = 0; int i = 0;
@@ -828,6 +882,7 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
if (!stateApplied && map != null) ApplySaved(); if (!stateApplied && map != null) ApplySaved();
if (cam == null) return; if (cam == null) return;
UpdateConstructionViews(); UpdateConstructionViews();
UpdateHero();
bool down, held, up; bool down, held, up;
Vector3 pos; Vector3 pos;
if (Input.touchCount > 0) if (Input.touchCount > 0)
@@ -898,6 +953,12 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
void Tap(Vector3 pos) void Tap(Vector3 pos)
{ {
// Тап по герою -- фраза и окно героя (как в 2D)
if (mode == Mode.View && City3DHero.Instance != null && !City3DHero.Instance.Hidden)
{
foreach (var hit in Physics.RaycastAll(cam.ScreenPointToRay(pos), 500f))
if (hit.collider.GetComponentInParent<City3DHero>() != null) { City3DHero.Instance.OnTapped(); return; }
}
if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return; if (!CellUnder(pos, mode == Mode.Place || mode == Mode.Move, out int cx, out int cy)) return;
if (mode == Mode.Place || mode == Mode.Move) if (mode == Mode.Place || mode == Mode.Move)
{ {
@@ -1015,6 +1076,27 @@ public class City3DController : MonoBehaviour, City3DSave.ILive
float guiScale = 1f; float guiScale = 1f;
GUIStyle button, label, box; GUIStyle button, label, box;
// ---------------- Герой у Терема (перенос из 2D) ----------------
// Терем достроен -- у его ворот герой; Терема нет -- героя нет. В режиме стройки и переноса прячется
void UpdateHero()
{
if (!stateApplied) return;
string castleId = null;
foreach (var pair in buildingType)
{
if (Types[pair.Value].Name != "Терем") continue;
if (!buildingViews.TryGetValue(pair.Key, out var view) || view == null || BuildTimer.IsConstructing(view.transform)) continue;
castleId = pair.Key;
break;
}
var hero = City3DHero.Instance;
if (castleId == null) { if (hero != null) Destroy(hero.gameObject); return; }
if (hero == null || hero.CastleId != castleId || hero.CastleView == null)
hero = City3DHero.Spawn(transform, map, castleId, buildingViews[castleId], () => townHallLevel);
hero.Hidden = mode == Mode.Place || mode == Mode.Move;
}
// ---------------- Стройка по времени в 3D ---------------- // ---------------- Стройка по времени в 3D ----------------
// BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым // BuildTimer затемняет только 2D-спрайты, поэтому в 3D здание, которое ещё строится, выглядело готовым
// (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11) // (Казармы «построены», а нанимать нельзя). Здесь: здание темнее и над ним -- «Стройка 0:45» (05.11)
+186
View File
@@ -0,0 +1,186 @@
using Bogatyri.Settlement.Placement;
using UnityEngine;
// ГЕРОЙ В 3D-ПОСЕЛЕНИИ (перенос из 2D, решение 05.11: всё из 2D-города -- в 3D). Как HeroWalker в 2D-городе:
// - живёт у Терема богатыря: появляется у его ворот, когда Терем достроен, и исчезает, если Терема нет;
// - гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше WalkRadius от Терема:
// шаг -- на соседнюю свободную клетку, поэтому сквозь здания и деревья не проходит; после нескольких шагов стоит 1-3 с;
// - тап по герою -- фраза и окно героя (окно Терема); в режиме стройки и переноса прячется;
// - пока без арта: фигурка из простых форм (тело, голова, шлем, щит, копьё). Арт подставим в BuildFigure.
// Только оживление города: на экономику, бой и сохранение не влияет. Создаёт и убирает City3DController.
public class City3DHero : MonoBehaviour
{
public static City3DHero Instance { get; private set; }
const float Speed = 1.3f; // клеток в секунду
const int WalkRadius = 6; // насколько далеко уходит от Терема
static readonly string[] Phrases =
{
"Город под защитой!",
"Веди, воевода, — не подведу.",
"Силушка богатырская при мне.",
"Дозором обхожу владения.",
"Есть ли где ворог? Только скажи.",
};
SettlementMap map;
System.Func<int> townHallLevel;
public string CastleId { get; private set; }
public GameObject CastleView { get; private set; }
Transform figure;
Vector2Int cell; // клетка, к которой идёт (или на которой стоит)
Vector2Int previous; // откуда пришёл -- чтобы не топтаться туда-обратно
int stepsLeft;
float waitLeft;
// Видимость: в режиме стройки и переноса герой прячется, чтобы не мешать ставить здания
public bool Hidden
{
get => figure != null && !figure.gameObject.activeSelf;
set { if (figure != null && figure.gameObject.activeSelf == value) figure.gameObject.SetActive(!value); }
}
public static City3DHero Spawn(Transform parent, SettlementMap map, string castleId, GameObject castleView, System.Func<int> townHallLevel)
{
if (Instance != null) Destroy(Instance.gameObject);
var go = new GameObject("Hero3D");
go.transform.SetParent(parent, false);
var h = go.AddComponent<City3DHero>();
h.map = map;
h.CastleId = castleId;
h.CastleView = castleView;
h.townHallLevel = townHallLevel;
h.BuildFigure();
h.cell = h.previous = h.Gate();
go.transform.position = CellWorld(h.cell);
h.waitLeft = 1f;
Instance = h;
return h;
}
void OnDestroy()
{
if (Instance == this) Instance = null;
}
// ---------------- Внешний вид (заглушка) ----------------
void BuildFigure()
{
figure = new GameObject("Figure").transform;
figure.SetParent(transform, false);
Part(PrimitiveType.Capsule, new Vector3(0, 0.32f, 0), new Vector3(0.28f, 0.3f, 0.22f), new Color(0.55f, 0.4f, 0.2f)); // кафтан
Part(PrimitiveType.Sphere, new Vector3(0, 0.72f, 0), new Vector3(0.2f, 0.2f, 0.2f), new Color(1f, 0.85f, 0.7f)); // голова
Part(PrimitiveType.Sphere, new Vector3(0, 0.8f, 0), new Vector3(0.22f, 0.14f, 0.22f), new Color(0.75f, 0.75f, 0.8f)); // шлем
Part(PrimitiveType.Cylinder, new Vector3(0, 0.9f, 0), new Vector3(0.03f, 0.06f, 0.03f), new Color(0.75f, 0.75f, 0.8f)); // шишак
Part(PrimitiveType.Cube, new Vector3(0.17f, 0.36f, 0.04f), new Vector3(0.04f, 0.26f, 0.2f), new Color(0.8f, 0.15f, 0.15f)); // щит
Part(PrimitiveType.Cylinder, new Vector3(-0.17f, 0.5f, 0), new Vector3(0.025f, 0.5f, 0.025f), new Color(0.45f, 0.3f, 0.15f)); // копьё
Part(PrimitiveType.Cylinder, new Vector3(0, 0.005f, 0), new Vector3(0.36f, 0.005f, 0.36f), new Color(0.18f, 0.22f, 0.14f)); // тень
// Тап по герою: коллайдер чуть больше фигурки (City3DController ищет City3DHero под пальцем)
var hit = gameObject.AddComponent<BoxCollider>();
hit.center = new Vector3(0, 0.5f, 0);
hit.size = new Vector3(0.7f, 1.1f, 0.7f);
}
void Part(PrimitiveType type, Vector3 pos, Vector3 scale, Color color)
{
var p = GameObject.CreatePrimitive(type);
Destroy(p.GetComponent<Collider>());
p.transform.SetParent(figure, false);
p.transform.localPosition = pos;
p.transform.localScale = scale;
p.GetComponent<Renderer>().sharedMaterial = Map3D.Mat(color);
}
// ---------------- Прогулка по клеткам ----------------
static Vector3 CellWorld(Vector2Int c)
{
float x = c.x + 0.5f, z = c.y + 0.5f;
return new Vector3(x, City3DLayout.HeightAt(x, z), z);
}
bool Walkable(int x, int y)
{
if (map == null || !map.InMap(x, y)) return false;
if (map.KindAt(x, y) != CellKind.Ground || !map.IsOpen(x, y, townHallLevel != null ? townHallLevel() : 1)) return false;
return map.BuildingAt(x, y) == null && map.ObstacleAt(x, y) == null;
}
bool CastleRect(out PlacedRect r)
{
r = default;
return map != null && map.TryGetBuilding(CastleId, out r);
}
bool NearHome(int x, int y)
{
if (!CastleRect(out var r)) return true;
float cx = r.X + r.W / 2f, cy = r.Y + r.H / 2f;
return Mathf.Abs(x + 0.5f - cx) <= WalkRadius + r.W / 2f && Mathf.Abs(y + 0.5f - cy) <= WalkRadius + r.H / 2f;
}
// Ворота: ближайшая свободная клетка вокруг Терема (с нижней стороны -- первой)
Vector2Int Gate()
{
if (!CastleRect(out var r)) return Vector2Int.zero;
for (int ring = 1; ring <= WalkRadius; ring++)
for (int x = r.X - ring; x < r.X + r.W + ring; x++)
foreach (int y in new[] { r.Y - ring, r.Y + r.H - 1 + ring })
if (Walkable(x, y)) return new Vector2Int(x, y);
return new Vector2Int(r.X + r.W / 2, r.Y - 1);
}
static readonly Vector2Int[] Dirs = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right };
// Следующая клетка: соседняя свободная рядом с Теремом; назад -- только если больше некуда
Vector2Int NextCell()
{
var options = new System.Collections.Generic.List<Vector2Int>();
foreach (var d in Dirs)
{
var c = cell + d;
if (c != previous && Walkable(c.x, c.y) && NearHome(c.x, c.y)) options.Add(c);
}
if (options.Count == 0 && Walkable(previous.x, previous.y)) return previous;
return options.Count > 0 ? options[Random.Range(0, options.Count)] : cell;
}
void Update()
{
if (Hidden) return;
if (!Walkable(cell.x, cell.y) && cell != Gate()) { cell = previous = Gate(); transform.position = CellWorld(cell); } // на клетку поставили здание
if (waitLeft > 0f) { waitLeft -= Time.deltaTime; figure.localPosition = Vector3.zero; return; }
Vector3 goal = CellWorld(cell), pos = transform.position;
Vector3 flat = new Vector3(goal.x - pos.x, 0, goal.z - pos.z);
if (flat.magnitude < 0.03f)
{
if (stepsLeft <= 0) { stepsLeft = Random.Range(3, 7); waitLeft = Random.Range(1f, 3f); return; }
stepsLeft--;
var next = NextCell();
previous = cell;
cell = next;
return;
}
Vector3 step = flat.normalized * Speed * Time.deltaTime;
if (step.magnitude > flat.magnitude) step = flat;
pos += step;
pos.y = City3DLayout.HeightAt(pos.x, pos.z); // по земле, через холмы
transform.position = pos;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(flat), Time.deltaTime * 10f); // смотрит, куда идёт
figure.localPosition = new Vector3(0f, Mathf.Abs(Mathf.Sin(Time.time * 10f)) * 0.04f, 0f); // покачивание при ходьбе
}
// ---------------- Тап ----------------
public void OnTapped()
{
UIKit.Toast("Богатырь: " + Phrases[Random.Range(0, Phrases.Length)]);
waitLeft = 2f; // остановился поговорить
if (CastleView != null) BuildingWindow.Open(CastleView);
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d4c2a7e1f5b4e93b6a1c0d9e2f73a58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+16 -3
View File
@@ -39,9 +39,22 @@ public sealed class City3DLayout
return hills; return hills;
} }
static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f); public static float RiverX(float z) => 37f + 3f * Mathf.Sin(z * 0.17f);
static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f; public static bool IsRiver(float x, float z) => Mathf.Abs(x - RiverX(z)) < 1.3f;
static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f; public static bool IsMountain(float x, float z) => x < 9f && z > 34f || x < 4f && z > 24f;
// Местность за краем участка (только вид, стройки нет): те же холмы, река и горы продолжаются без шва,
// к дальнему краю (SkirtCells) плавно опускаются до общей равнины SkirtFloor (06.11: при отдалении край участка
// выглядел обрезанным -- холмы и река обрывались уступом)
public const int SkirtCells = 60;
public const float SkirtFloor = -0.7f;
public static float SkirtHeight(float x, float z)
{
float outside = Mathf.Max(0f, Mathf.Max(Mathf.Max(-x, x - Size), Mathf.Max(-z, z - Size)));
float fade = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(SkirtCells - 20f, SkirtCells, outside));
return Mathf.Lerp(SkirtFloor, HeightAt(x, z), fade);
}
public static City3DLayout Create(PlacementRules rules = null) public static City3DLayout Create(PlacementRules rules = null)
{ {
@@ -231,7 +231,7 @@ public struct ArmyOnMap
} }
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения // Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
public enum ReconTargetKind { None, Settlement, Castle, Camp } public enum ReconTargetKind { None, Settlement, Castle, Camp, Army } // Army -- движущаяся армия (id "march_<номер>")
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта // Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
public struct ReconTarget public struct ReconTarget
@@ -26,6 +26,7 @@ public sealed class IntelligenceRules
public int[] ReconSettlementGameHours = { 2, 4 }; public int[] ReconSettlementGameHours = { 2, 4 };
public int[] ReconCastleGameHours = { 3, 6 }; public int[] ReconCastleGameHours = { 3, 6 };
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ) public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
public int[] ReconArmyGameHours = { 1, 2 }; // вражеская армия (ТЗ, раздел 6): II -- 1, III -- 2 игровых часа
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15% public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
public int MaxReports = 30; // храним последние отчёты public int MaxReports = 30; // храним последние отчёты
@@ -274,7 +275,8 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0; if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours : t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null; : t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours
: t.Kind == ReconTargetKind.Army ? rules.ReconArmyGameHours : null;
if (hours == null || hours.Length < level - 1) return 0; if (hours == null || hours.Length < level - 1) return 0;
return Math.Max(0, hours[level - 2]) * rules.GameHourMs; return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
} }
+100
View File
@@ -2182,4 +2182,104 @@ public class ModuleTests
Assert.AreEqual(5, m.survivors, "разведка без потерь"); Assert.AreEqual(5, m.survivors, "разведка без потерь");
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs)); Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
} }
// ---------------- Разведка М2: движущаяся армия (ТЗ разведки, раздел 6: II -- 1, III -- 2 игровых часа) ----------------
// Медленная чужая армия с долгим маршрутом рядом с игроком (настоящие патрули идут ~40 мин -- их часто не догнать,
// и это правильно: разведать можно армию, которая проходит рядом). Новые патрули не выходят: место занято ею
static WorldSimulation ArmyReconWorld(long t0, out MarchData patrol)
{
var world = PatrolWorld(true);
world.Patrols = new PatrolRules { Enabled = true, MaxActive = 1, IntervalMs = 24 * 3_600_000L };
patrol = new MarchData
{
id = 500, ownerId = "npc_test", fromNodeId = "city_14", targetNodeId = "city_15", soldiers = 20, survivors = 20,
departMs = t0, arriveMs = t0 + 10 * 3_600_000L, returnMs = t0 + 20 * 3_600_000L, state = MarchState.Outbound, patrol = true
};
world.LoadMarches(new List<MarchData> { patrol });
return world;
}
[Test]
public void ArmyRecon_Duration_1and2GameHours()
{
var map = new FakeMap();
map.Targets["march_7"] = new ReconTarget { Id = "march_7", Name = "Армия", Kind = ReconTargetKind.Army, Units = new[] { 20, 0, 0, 0 } };
var intel = Intel(map);
const long H = 5 * 60_000;
Assert.AreEqual(1 * H, intel.ReconDurationMs("march_7", 2), "вражеская армия, уровень II -- 1 игровой час");
Assert.AreEqual(2 * H, intel.ReconDurationMs("march_7", 3), "уровень III -- 2 игровых часа");
}
[Test]
public void ArmyRecon_Intercepts_FollowsTarget_ReportsThenReturns()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
long start = t0 + 60_000;
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, start, 2, H, out string error);
Assert.IsNotNull(m, error);
Assert.IsTrue(WorldSimulation.IsArmyRecon(m));
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
Assert.IsTrue(m.arriveMs > start, "путь до перехвата -- физический, не мгновенный");
Assert.AreEqual(m.arriveMs + H, m.gatherEndMs, "наблюдение -- 1 игровой час");
// Точка перехвата -- там, где армия в момент перехвата
world.TryGetPositionAt(patrol, m.arriveMs, out int px, out int py, out _, out _, out _);
Assert.AreEqual(px, m.meetX); Assert.AreEqual(py, m.meetY);
var events = new List<WorldEvent>();
world.Advance(m.arriveMs, events);
Assert.AreEqual(WorldEventType.ScoutArrived, events[events.Count - 1].Type);
Assert.AreEqual(MarchState.Gathering, m.state, "догнали -- наблюдают");
// Во время наблюдения разведчики идут рядом с армией
long mid = m.arriveMs + H / 2;
world.TryGetPositionAt(patrol, mid, out int ax, out int ay, out _, out _, out _);
world.TryGetPositionAt(m, mid, out int sx, out int sy, out _, out _, out _);
Assert.AreEqual(ax, sx); Assert.AreEqual(ay, sy);
events.Clear();
world.Advance(m.returnMs, events);
var mine = events.FindAll(e => e.March == m);
Assert.AreEqual(WorldEventType.ReconCompleted, mine[0].Type, "сначала отчёт");
Assert.AreEqual(m.gatherEndMs, mine[0].TimeMs, "отчёт -- на конец наблюдения");
Assert.AreEqual(WorldEventType.MarchReturned, mine[1].Type, "потом -- домой");
Assert.AreEqual(5, m.survivors, "разведка без боя и потерь");
Assert.IsTrue(m.returnMs > m.gatherEndMs, "домой идут пешком от места, где кончилось наблюдение");
}
[Test]
public void ArmyRecon_Refused_IfArmyLeavesBeforeObservationEnds_OrIsOwn()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
int before = world.Marches.Count;
// Армия почти дома: наблюдение на 2 часа не успеть
long late = patrol.returnMs - 30_000;
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, late, 3, 2 * H, out string err));
Assert.IsTrue(err.Contains("не догнать") || err.Contains("раньше"), err);
Assert.AreEqual(before, world.Marches.Count, "поход не создан");
Assert.IsNull(world.StartArmyRecon(patrol.ownerId, WorldMapLayout.PlayerCityId, patrol.id, 5, t0, 2, H, out err), "свою армию не разведывают");
Assert.IsNull(world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, 99999, 5, t0, 2, H, out err), "нет такой армии");
}
[Test]
public void ArmyRecon_TargetGone_BeforeIntercept_ReturnsWithoutReport()
{
const long H = 5 * 60_000;
long t0 = 1_000_000_000_000;
var world = ArmyReconWorld(t0, out MarchData patrol);
MarchData m = world.StartArmyRecon(LocalPlayer.Id, WorldMapLayout.PlayerCityId, patrol.id, 5, t0 + 1000, 2, H, out string error);
Assert.IsNotNull(m, error);
world.Patrols = new PatrolRules { Enabled = false }; // патрули выключили -- цель пропала
var events = new List<WorldEvent>();
world.SpawnPatrolsUntil(t0 + 2000);
world.Advance(m.returnMs + 24 * 3_600_000L, events);
Assert.IsTrue(m.targetEmpty, "цели нет -- дошли до места и вернулись");
Assert.IsFalse(events.Exists(e => e.March == m && e.Type == WorldEventType.ReconCompleted), "без отчёта");
Assert.IsTrue(events.Exists(e => e.March == m && e.Type == WorldEventType.MarchReturned), "вернулись");
}
} }
+54 -5
View File
@@ -179,6 +179,21 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
public bool TryGetReconTarget(string targetId, out ReconTarget target) public bool TryGetReconTarget(string targetId, out ReconTarget target)
{ {
target = default; target = default;
// Движущаяся армия ("march_<номер>", как в GetArmies): состав -- на этот момент, без стен и ресурсов
if (TryParseArmyId(targetId, out int marchId))
{
MarchData m = World.FindMarch(marchId);
if (m == null || m.ownerId == LocalPlayerId) return false;
int[] units = m.units != null && m.units.Length == TroopType.Count ? (int[])m.units.Clone() : new[] { m.soldiers, 0, 0, 0 };
MapNode home = World.GetNode(m.fromNodeId);
target = new ReconTarget
{
Id = targetId, Name = home != null ? $"Армия из «{home.displayName}»" : "Чужая армия", Kind = ReconTargetKind.Army,
Units = units, Fortified = false, HasHero = false, // героев пока нет
ResourceNames = new string[0], ResourceAmounts = new int[0]
};
return true;
}
MapNode node = World.GetNode(targetId); MapNode node = World.GetNode(targetId);
if (node == null) return false; if (node == null) return false;
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
@@ -228,7 +243,39 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return TryStart(targetId, soldiers, true, out error, level, hold); return TryStart(targetId, soldiers, true, out error, level, hold);
} }
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0) public static bool TryParseArmyId(string id, out int marchId)
{
marchId = 0;
return id != null && id.StartsWith("march_") && int.TryParse(id.Substring(6), out marchId) && marchId > 0;
}
// Цель отчёта разведки этого похода: армия ("march_<номер>") или узел карты
public static string ReconTargetId(MarchData march) =>
WorldSimulation.IsArmyRecon(march) ? "march_" + march.targetMarchId : march.targetNodeId;
// Разведка движущейся армии (Разведка М2, ТЗ раздел 6): отряд догоняет армию, идёт рядом время наблюдения,
// приносит отчёт и возвращается. armyId -- как в GetArmies ("march_<номер>")
public bool TryStartArmyRecon(string armyId, int soldiers, int level, out string error)
{
if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; }
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
long hold = intel.ReconDurationMs(armyId, level);
if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; }
return TryStart(armyId, soldiers, true, out error, level, hold, targetMarchId);
}
// План разведки армии без старта: когда догонят, когда отчёт, когда дома (как для пехоты -- состав ещё не выбран)
public bool TryPlanArmyRecon(string armyId, int level, out long meetMs, out long reportMs, out long homeMs, out string error)
{
meetMs = reportMs = homeMs = 0;
if (!TryParseArmyId(armyId, out int targetMarchId)) { error = "Это не армия"; return false; }
long hold = GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(armyId, level) : 0;
if (hold <= 0) { error = "Эту армию разведать нельзя"; return false; }
return World.TryPlanArmyRecon(WorldMapLayout.PlayerCityId, targetMarchId, NowMs, hold, out meetMs, out reportMs, out homeMs, out error);
}
bool TryStart(string targetId, int soldiers, bool scout, out string error, int reconLevel = 0, long reconHoldMs = 0, int targetMarchId = 0)
{ {
if (!GameServices.TryGet(out IArmy army)) if (!GameServices.TryGet(out IArmy army))
{ {
@@ -250,9 +297,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
return false; return false;
} }
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout); MarchData march = targetMarchId > 0
? World.StartArmyRecon(LocalPlayerId, WorldMapLayout.PlayerCityId, targetMarchId, soldiers, NowMs, reconLevel, reconHoldMs, out error)
: World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
if (march == null) return false; if (march == null) return false;
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs); if (reconLevel > 0 && targetMarchId == 0) World.SetRecon(march, reconLevel, reconHoldMs);
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов. // Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал. // Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
@@ -266,7 +315,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
World.AssignUnits(march, army.GetMarchUnits(march.id)); World.AssignUnits(march, army.GetMarchUnits(march.id));
if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow(); if (GameServices.TryGet(out ISaveSystem save)) save.SaveNow();
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}"); Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.TargetName(march)}");
OnMarchStarted?.Invoke(march); OnMarchStarted?.Invoke(march);
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout }); EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
if (reconLevel > 0) if (reconLevel > 0)
@@ -280,7 +329,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
void HandleReconCompleted(MarchData march) void HandleReconCompleted(MarchData march)
{ {
if (GameServices.TryGet(out IIntelligence intel)) if (GameServices.TryGet(out IIntelligence intel))
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs); intel.CompleteRecon(ReportId(march), ReconTargetId(march), march.reconLevel, march.gatherEndMs);
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}"); Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
OnReconCompleted?.Invoke(march); OnReconCompleted?.Invoke(march);
} }
@@ -0,0 +1,169 @@
using System;
namespace Bogatyri.World
{
// РАЗВЕДКА ДВИЖУЩЕЙСЯ АРМИИ (Разведка М2, ТЗ разведки, раздел 6: «вражеская армия -- 1 / 2 игровых часа»).
// Весь путь -- физический марш, без телепорта и без постоянного слежения:
// 1) отряд идёт из города к точке перехвата -- где армия окажется, когда отряд до неё дойдёт (по маршруту армии
// и скорости отряда, с шагом InterceptStepMs);
// 2) наблюдает reconHoldMs, идя рядом с армией (позиция отряда = позиция армии);
// 3) отчёт на конец наблюдения, затем домой из места, где наблюдение кончилось.
// Всё время похода известно при выступлении (маршрут армии известен Миру), поэтому офлайн считается так же.
// Догнать нельзя или армия уйдёт с карты раньше конца наблюдения -- поход не начинается (ошибка для игрока).
// Армия-цель исчезла до перехвата (патрули выключили) -- отряд дошёл до точки и вернулся (targetEmpty).
public sealed partial class WorldSimulation
{
public const long InterceptStepMs = 10_000;
// Поход ли это на армию (а не на узел карты)
public static bool IsArmyRecon(MarchData m) => m != null && m.targetMarchId > 0;
public MarchData FindMarch(int id) => marches.Find(m => m.id == id);
// Начать разведку армии. Состав ещё неизвестен -- план как для пехоты; AssignUnits пересчитает по составу
public MarchData StartArmyRecon(string ownerId, string fromId, int targetMarchId, int soldiers, long nowMs, int level, long holdMs, out string error)
{
MapNode from = GetNode(fromId);
MarchData target = FindMarch(targetMarchId);
if (from == null) { error = "Неизвестная точка на карте"; return null; }
if (target == null || !TryGetPositionAt(target, nowMs, out _, out _, out _, out _, out _)) { error = "Этой армии уже нет на карте"; return null; }
if (target.ownerId == ownerId) { error = "Это ваша армия"; return null; }
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
if (level < 2 || level > 3 || holdMs <= 0) { error = "Уровень разведки -- II или III"; return null; }
var march = new MarchData
{
id = nextMarchId++,
ownerId = ownerId,
fromNodeId = fromId,
targetNodeId = target.targetNodeId, // узел, куда идёт армия (для старого кода, которому нужен узел)
targetMarchId = targetMarchId,
targetName = "чужая армия",
soldiers = soldiers,
survivors = soldiers,
departMs = nowMs,
state = MarchState.Outbound,
scout = true,
reconLevel = level,
reconHoldMs = holdMs,
};
if (!PlanArmyRecon(march, null, out error)) { nextMarchId--; return null; }
march.battleSeed = BattleSeed(march.id, nowMs, "march_" + targetMarchId);
marches.Add(march);
return march;
}
// Рассчитать поход без старта (для окна: «догонят через N, вернутся через M»)
public bool TryPlanArmyRecon(string fromId, int targetMarchId, long nowMs, long holdMs, out long meetMs, out long endMs, out long returnMs, out string error)
{
var probe = new MarchData { fromNodeId = fromId, targetMarchId = targetMarchId, departMs = nowMs, reconHoldMs = holdMs, scout = true, reconLevel = 2 };
bool ok = PlanArmyRecon(probe, null, out error);
meetMs = probe.arriveMs; endMs = probe.gatherEndMs; returnMs = probe.returnMs;
return ok;
}
// Перехват, наблюдение, обратный путь -- по составу units (null -- как пехота)
bool PlanArmyRecon(MarchData m, int[] units, out string error)
{
error = null;
MapNode home = GetNode(m.fromNodeId);
MarchData target = FindMarch(m.targetMarchId);
if (home == null || target == null) { error = "Этой армии уже нет на карте"; return false; }
// 1) Перехват: первый момент, когда отряд успевает дойти до места, где в этот момент будет армия
long limit = target.returnMs > 0 ? target.returnMs : m.departMs + 48L * 3_600_000;
long meet = -1;
int mx = 0, my = 0;
for (long t = m.departMs; t <= limit; t += InterceptStepMs)
{
if (!TryGetPositionAt(target, t, out int x, out int y, out _, out _, out _)) break; // армия ушла с карты
if (m.departMs + TravelTimeMs(home, Point(x, y), units) <= t) { meet = t; mx = x; my = y; break; }
}
if (meet < 0) { error = "Армию не догнать: она уйдёт раньше, чем разведчики до неё доберутся"; return false; }
// 2) Наблюдение рядом с армией: она должна оставаться на карте до конца
long end = meet + m.reconHoldMs;
if (!TryGetPositionAt(target, end, out int lx, out int ly, out _, out _, out _))
{
error = "Армия уйдёт с карты раньше, чем закончится наблюдение";
return false;
}
// 3) Домой -- из места, где наблюдение кончилось
m.arriveMs = meet;
m.meetX = mx; m.meetY = my;
m.gatherEndMs = end;
m.leaveX = lx; m.leaveY = ly;
m.returnMs = end + TravelTimeMs(Point(lx, ly), home, units);
return true;
}
// Отряд дошёл до точки перехвата: армия на месте -- наблюдает (время уже рассчитано); армии нет -- домой
void ArriveArmyRecon(MarchData m)
{
MapNode home = GetNode(m.fromNodeId);
int[] units = HasUnits(m) ? m.units : null;
m.victory = false;
m.survivors = m.soldiers;
m.survivorUnits = units != null ? (int[])units.Clone() : null;
m.loot = new System.Collections.Generic.List<RewardEntry>();
MarchData target = FindMarch(m.targetMarchId);
m.targetEmpty = target == null || !TryGetPositionAt(target, m.arriveMs, out _, out _, out _, out _, out _);
if (m.targetEmpty)
{
m.gatherEndMs = 0;
m.leaveX = m.meetX; m.leaveY = m.meetY;
m.returnMs = m.arriveMs + TravelTimeMs(Point(m.meetX, m.meetY), home, units);
m.state = MarchState.Returning;
return;
}
m.state = MarchState.Gathering; // наблюдает до gatherEndMs, обратный путь уже рассчитан
}
// Позиция отряда разведки армии в момент t
bool TryGetArmyReconPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
{
x = y = toX = toY = 0;
soldiers = m.soldiers;
MapNode home = GetNode(m.fromNodeId);
if (home == null || t < m.departMs) return false;
if (t < m.arriveMs)
{
LerpXY(home.x, home.y, m.meetX, m.meetY, m.departMs, m.arriveMs, t, out x, out y);
toX = m.meetX; toY = m.meetY;
return true;
}
long backStart = m.targetEmpty || m.gatherEndMs <= 0 ? m.arriveMs : m.gatherEndMs;
if (t < backStart)
{
// идёт рядом с армией; армии уже нет (не должно быть по плану) -- стоит, где кончилось наблюдение
MarchData target = FindMarch(m.targetMarchId);
if (target == null || !TryGetPositionAt(target, t, out x, out y, out toX, out toY, out _)) { x = m.leaveX; y = m.leaveY; toX = x; toY = y; }
return true;
}
if (m.returnMs > 0 && t >= m.returnMs) return false; // дома
LerpXY(m.leaveX, m.leaveY, home.x, home.y, backStart, m.returnMs, t, out x, out y);
toX = home.x; toY = home.y;
return true;
}
// Отрезок для отрисовки: точки как временные узлы карты (не входят в Nodes)
void GetArmyReconSegment(MarchData m, long nowMs, out MapNode from, out MapNode to, out double progress)
{
TryGetArmyReconPositionAt(m, nowMs, out int x, out int y, out int tx, out int ty, out _);
from = Point(x, y);
to = Point(tx, ty);
progress = 0;
}
static MapNode Point(int x, int y) => new MapNode { id = "", x = x, y = y, displayName = "" };
static void LerpXY(int ax, int ay, int bx, int by, long start, long end, long t, out int x, out int y)
{
if (end <= start) { x = bx; y = by; return; }
long p = Math.Max(0, Math.Min(end - start, t - start));
x = ax + (int)((long)(bx - ax) * p / (end - start));
y = ay + (int)((long)(by - ay) * p / (end - start));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2a7f5c3d9e1b4a68b4c2d0e8f6a19b37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -155,6 +155,7 @@ namespace Bogatyri.World
public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers) public bool TryGetPositionAt(MarchData m, long t, out int x, out int y, out int toX, out int toY, out int soldiers)
{ {
x = y = toX = toY = soldiers = 0; x = y = toX = toY = soldiers = 0;
if (IsArmyRecon(m)) return TryGetArmyReconPositionAt(m, t, out x, out y, out toX, out toY, out soldiers);
MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId); MapNode home = GetNode(m.fromNodeId), target = GetNode(m.targetNodeId);
if (home == null || target == null || t < m.departMs) return false; if (home == null || target == null || t < m.departMs) return false;
@@ -32,6 +32,9 @@ namespace Bogatyri.World
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты
public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты)
public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла) public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
public int cargo; // сколько ресурса несёт войско (известно с начала сбора) public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
public int defenders; // гарнизон цели в момент боя public int defenders; // гарнизон цели в момент боя
@@ -200,6 +203,7 @@ namespace Bogatyri.World
{ {
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false; if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
m.units = units; m.units = units;
if (IsArmyRecon(m)) { PlanArmyRecon(m, units, out _); return true; } // перехват -- по скорости состава
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units); m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
return true; return true;
} }
@@ -373,7 +377,7 @@ namespace Bogatyri.World
// Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома // Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома
if (nextKind == 0) if (nextKind == 0)
{ {
ArriveScout(next); if (IsArmyRecon(next)) ArriveArmyRecon(next); else ArriveScout(next);
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs }); events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
} }
else if (nextKind == 1) else if (nextKind == 1)
@@ -544,6 +548,7 @@ namespace Bogatyri.World
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка. // Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress) public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
{ {
if (IsArmyRecon(m)) { GetArmyReconSegment(m, nowMs, out segmentFrom, out segmentTo, out progress); return; }
MapNode home = GetNode(m.fromNodeId); MapNode home = GetNode(m.fromNodeId);
MapNode target = GetNode(m.targetNodeId); MapNode target = GetNode(m.targetNodeId);
@@ -143,10 +143,14 @@ public class IntelligenceOverlay : MonoBehaviour
} }
// Имя владельца -- по его поселению на карте («npc_3» -> «Велимир») // Имя владельца -- по его поселению на карте («npc_3» -> «Велимир»)
string OwnerName(string ownerId) string OwnerName(string ownerId) => OwnerName(world, ownerId);
// Имя владельца армии -- по его городу на карте (окно чужой армии -- тоже)
public static string OwnerName(WorldSimulation world, string ownerId)
{ {
foreach (var n in world.Nodes) if (world != null)
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName; foreach (var n in world.Nodes)
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
return ownerId ?? "?"; return ownerId ?? "?";
} }
+4 -2
View File
@@ -85,7 +85,9 @@ public class MarchView : MonoBehaviour
void CreateRouteLine(WorldSimulation world, MarchData march) void CreateRouteLine(WorldSimulation world, MarchData march)
{ {
Vector3 home = WorldMapController.ToWorld(world.GetNode(march.fromNodeId)); Vector3 home = WorldMapController.ToWorld(world.GetNode(march.fromNodeId));
Vector3 target = WorldMapController.ToWorld(world.GetNode(march.targetNodeId)); Vector3 target = WorldSimulation.IsArmyRecon(march)
? new Vector3(march.meetX / 1000f, 0f, march.meetY / 1000f) // разведка армии -- до точки перехвата
: WorldMapController.ToWorld(world.GetNode(march.targetNodeId));
Vector3 delta = target - home; Vector3 delta = target - home;
// Тонкая полоса на земле от города до цели // Тонкая полоса на земле от города до цели
@@ -126,7 +128,7 @@ public class MarchView : MonoBehaviour
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000); long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой") string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
: gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}") : gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}")
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой"; : WorldSimulation.IsArmyRecon(march) ? "догоняют армию" : march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30» // Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
string time = secondsLeft >= 3600 string time = secondsLeft >= 3600
? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}" ? $"{secondsLeft / 3600}:{secondsLeft / 60 % 60:00}:{secondsLeft % 60:00}"
@@ -49,6 +49,41 @@ public static class ReconReportText
return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин"; return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин";
} }
// Окно чужой армии (Разведка М2): что видно сейчас, последний отчёт и «Разведка II / III» -- отряд догонит армию,
// пойдёт рядом время наблюдения и вернётся с отчётом. Воинов -- до ArmyReconSoldiers из свободных
public const int ArmyReconSoldiers = 10;
public static void OpenArmyWindow(KnownContact army, System.Action<string> hint)
{
var card = UIKit.Modal("ЧУЖАЯ АРМИЯ", out GameObject root);
long now = MarchManager.NowMs;
string owner = army.Identified ? IntelligenceOverlay.OwnerName(MarchManager.Instance.World, army.OwnerId) : "владелец неизвестен";
UIKit.Text(card, $"~{army.ApproxMin}–{army.ApproxMax} воинов, {owner}", 26, UIKit.Theme.Text, TextAlignmentOptions.Center);
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(army.ArmyId, out var report))
UIKit.Text(card, Format(report, now), 22, UIKit.Theme.TextDim, TextAlignmentOptions.Left);
int free = GameServices.TryGet(out IArmy myArmy) ? myArmy.AvailableForMarch : 0;
int soldiers = Mathf.Min(ArmyReconSoldiers, free);
UIKit.Text(card, soldiers > 0 ? $"Отправить {soldiers} воинов: догонят армию, пойдут рядом и вернутся с отчётом. Без боя."
: "Нет свободных воинов для разведки", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
for (int level = 2; level <= 3; level++)
{
int lv = level;
string name = lv == 3 ? "III" : "II";
bool ok = MarchManager.Instance.TryPlanArmyRecon(army.ArmyId, lv, out long meet, out long reportAt, out long home, out string error);
string plan = ok ? $"догонят через {Ago(meet - now)}, отчёт через {Ago(reportAt - now)}, дома через {Ago(home - now)}" : error;
UIKit.Text(card, $"Разведка {name}: {plan}", 22, ok ? UIKit.Theme.Text : UIKit.Theme.Gold, TextAlignmentOptions.Center);
var b = UIKit.Button(card, $"Разведка {name}", ok && soldiers > 0 ? UIKit.Theme.Blue : UIKit.Theme.Gray, () =>
{
bool started = MarchManager.Instance.TryStartArmyRecon(army.ArmyId, soldiers, lv, out string err);
hint?.Invoke(started ? $"Разведка {name}: {soldiers} воинов идут догонять армию" : "Разведка не начата: " + err);
Object.Destroy(root);
}, -1, 80, 26);
b.interactable = ok && soldiers > 0;
}
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28);
}
// Окно «Отчёты»: последние отчёты, новые сверху // Окно «Отчёты»: последние отчёты, новые сверху
public static void OpenWindow() public static void OpenWindow()
{ {
+20 -1
View File
@@ -510,6 +510,9 @@ public class WorldMapController : MonoBehaviour
if (ArmiesWindow.IsOpen) return; if (ArmiesWindow.IsOpen) return;
if (!mapCamera.TryGetTap(out Vector3 world)) return; if (!mapCamera.TryGetTap(out Vector3 world)) return;
// Видимая чужая армия под пальцем -- окно разведки армии (Разведка М2)
if (FindArmyAt(world, out KnownContact army)) { ReconReportText.OpenArmyWindow(army, s => hintText.text = s); return; }
MapNode node = FindNodeAt(world); MapNode node = FindNodeAt(world);
if (node == null) return; if (node == null) return;
@@ -521,6 +524,22 @@ public class WorldMapController : MonoBehaviour
else if (node.canBeAttacked) OpenAttackPanel(node); else if (node.canBeAttacked) OpenAttackPanel(node);
} }
// Чужая армия в обзоре рядом с точкой нажатия (метка-ромб размером ~0,55 клетки; зона чуть больше)
static bool FindArmyAt(Vector3 world, out KnownContact found)
{
found = default;
if (!GameServices.TryGet(out IIntelligence intel)) return false;
float best = 0.7f;
bool any = false;
foreach (var c in intel.Contacts)
{
if (!c.Observed) continue;
float d = Vector2.Distance(new Vector2(world.x, world.z), new Vector2(c.X / 1000f, c.Y / 1000f));
if (d <= best) { best = d; found = c; any = true; }
}
return any;
}
MapNode FindNodeAt(Vector3 world) MapNode FindNodeAt(Vector3 world)
{ {
foreach (var node in World.Nodes) foreach (var node in World.Nodes)
@@ -841,7 +860,7 @@ public class WorldMapController : MonoBehaviour
void HandleReconCompleted(MarchData march) void HandleReconCompleted(MarchData march)
{ {
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report)) if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(MarchManager.ReconTargetId(march), out var report))
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой"); EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
} }
+4 -1
View File
@@ -58,7 +58,7 @@
| Поселение (соседний город) | 2 игровых часа | 4 | | Поселение (соседний город) | 2 игровых часа | 4 |
| Замок | 3 | 6 | | Замок | 3 | 6 |
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 | | Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | | | Вражеская армия | 1 | 2 |
1 игровой час = 5 реальных минут. 1 игровой час = 5 реальных минут.
@@ -69,6 +69,8 @@
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30. - События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`). - Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
**Разведка армии (06.11):** тап по видимой чужой армии (красный ромб) — окно «ЧУЖАЯ АРМИЯ»: примерная численность, последний отчёт и «Разведка II / III» для 10 свободных воинов с планом «догонят через …, отчёт через …, дома через …». Отряд физически идёт к точке перехвата (где армия будет, когда он дойдёт), наблюдает, идя рядом с ней, отчёт — на конец наблюдения, домой — из места, где наблюдение кончилось. Не догнать или армия уйдёт с карты раньше конца наблюдения — поход не начинается, причина в окне. Армия пропала до перехвата — отряд дошёл до точки и вернулся без отчёта. Цель отчёта — `march_<номер>` (как в `GetArmies`), снимок — `MarchManager.TryGetReconTarget` (`ReconTargetKind.Army`: точный состав, без стен и ресурсов). Время — `IntelligenceRules.ReconArmyGameHours` {1, 2}. Патрули-заглушки идут ~40 минут — их можно догнать, только если они проходят рядом с городом.
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему. **На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
## Патрули-заглушки (временно) ## Патрули-заглушки (временно)
@@ -132,6 +134,7 @@
| Дата | Кто | Что | | Дата | Кто | Что |
| --- | --- | --- | | --- | --- | --- |
| 06.11.2026 | Claude (Саша) | М2: разведка движущейся армии — тап по чужой армии, «Разведка II / III» (1 / 2 игровых часа), перехват и сопровождение физическим маршем, отчёт и возвращение; `ReconTargetKind.Army`, `ReconArmyGameHours`. Тесты: время, перехват и сопровождение, отказ, цель пропала. |
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. | | 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
| 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. | | 04.11.2026 | Claude | 2.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. | | 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
+2
View File
@@ -119,6 +119,8 @@
| Дата | Кто | Что | | Дата | Кто | Что |
| --- | --- | --- | | --- | --- | --- |
| 06.11.2026 | Claude (Саша) | 3D: местность за краем участка — 60 клеток вокруг (`City3DController.BuildSkirt`, `City3DLayout.SkirtHeight`): те же холмы, река и горы продолжаются без шва (на стыке те же вершины) и к дальнему краю плавно опускаются до равнины; река течёт дальше, вокруг лес. Только вид: без коллайдера, стройки и тапов нет. Раньше при отдалении участок обрывался уступом. |
| 06.11.2026 | Claude (Саша) | Герой у Терема в 3D (перенос из 2D): `City3D/City3DHero.cs` — фигурка из простых форм появляется у ворот достроенного Терема и гуляет по свободным клеткам 3D-карты (земля, открытая зона, без зданий и деревьев) не дальше 6 клеток от Терема, шагами на соседнюю клетку — сквозь здания не проходит; тап — фраза и окно героя; в режиме стройки и переноса прячется. Создаёт и убирает `City3DController.UpdateHero`. На экономику и сохранение не влияет. |
| 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. | | 06.11.2026 | Claude (Саша) | Открытие зоны Ратушей: камера показывает только край новой зоны — старую зону и 2 клетки новой за её границей (`ZoneEdgeCells`), а не всю зону; ближе, чем была, не подъезжает (`City3DCamera.FocusOn(..., onlyZoomOut)`). Раньше при ускорении улучшения Ратуши камера улетала слишком далеко. |
| 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. | | 06.11.2026 | Claude (Валдемар) | Этап 1 перехода на 3D (2D-город больше не развиваем). Сверка: все 12 зданий 2D (8 `Data_*.asset` + Склад, Амбар, Госпиталь, Терем) есть в 3D с теми же ценами и уровнем Ратуши; ни одна кнопка не ведёт в MVP1. Камера (`City3DCamera`): центр кадра не уходит за открытые зоны; при самом дальнем отдалении открытая зона видна целиком между HUD сверху и снизу — дальше всего = max(подобранное в DEV, сколько нужно для зоны), считается по проекции камеры (`FitDistance`); «+ / −» и колесо приближают плавно; перетаскивание считается на высоте земли под пальцем (на холмах карта идёт точно за пальцем); подъезд к призраку — `MoveTo`, без смены приближения. Ввод: после щипка отпущенный палец больше не считается тапом (не открывается окно здания). `SceneListFixer` больше не добавляет и не включает MVP1 в Build Settings. Числа камеры — ТЕСТ. |
| 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. | | 05.11.2026 | Claude (Саша) | Перенос 2D-города надёжнее: если места нет, деревья и валуны под зданием расчищаются (бесплатно, без награды — `SettlementMap.ValidateIfCleared`, `ObstaclesUnder`, тест `Placement_IfCleared_IgnoresOnlyTreesAndListsThem`); если места нет и так — здание не теряется, а ждёт в `settlement3d.pendingFrom2D` и ставится при входе в город или когда Ратуша открывает зону. DEV → «Камера»: ползунки наклона, перспективы (поле зрения), ближе / дальше всего и текущего отдаления; подобранное запоминается на устройстве (только редактор и отладочная сборка), «Сбросить» — числа из сцены. |
+1
View File
@@ -158,6 +158,7 @@
| Дата | Кто | Что | | Дата | Кто | Что |
| --- | --- | --- | | --- | --- | --- |
| 06.11.2026 | Claude (Саша) | Поход на движущуюся армию (Разведка М2): `MarchData.targetMarchId`, `meetX/meetY`, `leaveX/leaveY`; `WorldSimulation.ArmyRecon.cs` — `StartArmyRecon`, `TryPlanArmyRecon` (перехват с шагом 10 с, наблюдение рядом с армией, путь домой от места конца наблюдения), позиция и отрезок для отрисовки; `MarchManager.TryStartArmyRecon`, `TryPlanArmyRecon`, `ReconTargetId`. Всё время похода известно при выступлении — офлайн считается так же. |
| 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. | | 04.11.2026 | Claude | Разведка М2: `IWorld.TryStartRecon`, `MarchData.reconLevel` / `reconHoldMs`, отряд наблюдает у цели (`Gathering`) и идёт домой от конца наблюдения, `WorldEventType.ReconCompleted`; `IWorldMap.TryGetReconTarget` — снимок цели для отчёта. |
| 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. | | 04.11.2026 | Claude | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. | | 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |