Feature/3d hud #18
@@ -202,6 +202,8 @@ public interface IWorld
|
||||
bool TryStartMarch(string targetId, int soldiers, out string error);
|
||||
// Поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться -- без боя, сбора и потерь, занимает слот похода
|
||||
bool TryStartScout(string targetId, int soldiers, out string error);
|
||||
// Разведка с отчётом (Разведка М2): дойти, наблюдать время уровня (II / III), отчёт, вернуться
|
||||
bool TryStartRecon(string targetId, int soldiers, int level, out string error);
|
||||
}
|
||||
|
||||
// ---------------- Мир -> Разведка: что где находится (решение №46) ----------------
|
||||
@@ -216,6 +218,22 @@ public struct ArmyOnMap
|
||||
public int Soldiers; // точная численность -- разведка показывает игроку только примерную
|
||||
}
|
||||
|
||||
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
|
||||
public enum ReconTargetKind { None, Settlement, Castle, Camp }
|
||||
|
||||
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
|
||||
public struct ReconTarget
|
||||
{
|
||||
public string Id;
|
||||
public string Name;
|
||||
public ReconTargetKind Kind;
|
||||
public int[] Units; // гарнизон по типам (TroopType)
|
||||
public bool Fortified; // защитники за стенами
|
||||
public bool HasHero;
|
||||
public string[] ResourceNames; // что можно взять (имена ResourceType)
|
||||
public int[] ResourceAmounts;
|
||||
}
|
||||
|
||||
public interface IWorldMap
|
||||
{
|
||||
int HalfWidth { get; } // границы карты от центра, милли-единицы
|
||||
@@ -223,6 +241,8 @@ public interface IWorldMap
|
||||
bool TryGetSettlement(string ownerId, out int x, out int y); // где поселение игрока
|
||||
// Все армии на карте в момент timeMs (в том числе в прошлом -- для расчёта, пока игра была закрыта)
|
||||
void GetArmies(long timeMs, List<ArmyOnMap> result);
|
||||
// Цель разведки: замок, поселение, лагерь (Разведка М2). false -- такой цели нет или её нельзя разведать
|
||||
bool TryGetReconTarget(string targetId, out ReconTarget target);
|
||||
}
|
||||
|
||||
// ---------------- Разведка (Bogatyri.Intelligence): туман войны, наблюдение ----------------
|
||||
@@ -245,12 +265,38 @@ public struct KnownContact
|
||||
public long LastSeenMs; // когда видели последний раз
|
||||
}
|
||||
|
||||
// Отчёт разведки (ТЗ разведки, раздел 6): сведения на момент операции, без постоянного слежения.
|
||||
// Уровень II -- точное число и типы, герой, укрепления; III -- плюс модификаторы и оценка ресурсов ±15%
|
||||
public struct ReconReport
|
||||
{
|
||||
public string Id; // устойчивый: "recon_<номер похода>"
|
||||
public string TargetId;
|
||||
public string TargetName;
|
||||
public int Level; // 2 или 3
|
||||
public long TimeMs; // когда закончилось наблюдение (по часам игры)
|
||||
public int[] Units; // гарнизон по типам на момент отчёта
|
||||
public bool Fortified;
|
||||
public bool HasHero;
|
||||
public string Modifiers; // уровень III: основные боевые модификаторы защиты
|
||||
public string[] ResourceNames; // уровень III: оценка ресурсов
|
||||
public int[] ResourceMin, ResourceMax;
|
||||
}
|
||||
|
||||
public interface IIntelligence
|
||||
{
|
||||
int ObservationLevel { get; set; } // технология «Обзор»: 0..3 (+10 / +20 / +30% к радиусам)
|
||||
MapVisibility GetVisibility(int x, int y); // по последнему расчёту
|
||||
IReadOnlyList<KnownContact> Contacts { get; }
|
||||
void ObserveUntil(long nowMs); // досчитать наблюдение до момента (в том числе офлайн)
|
||||
|
||||
// ---- М2: целенаправленная разведка ----
|
||||
// Сколько разведчики наблюдают цель (мс часов игры): поселение 2 / 4, замок 3 / 6 игровых часов (уровень II / III).
|
||||
// 0 -- цель нельзя разведать с отчётом
|
||||
long ReconDurationMs(string targetId, int level);
|
||||
// Наблюдение закончено: составить отчёт по снимку цели на этот момент. Один раз на reportId (повтор -- false)
|
||||
bool CompleteRecon(string reportId, string targetId, int level, long timeMs);
|
||||
IReadOnlyList<ReconReport> Reports { get; }
|
||||
bool TryGetLatestReport(string targetId, out ReconReport report);
|
||||
}
|
||||
|
||||
// ---------------- Боевая система: урон, потери, исход ----------------
|
||||
|
||||
@@ -198,6 +198,23 @@ public struct ArmyLostContactEvent // чужая армия вышла из з
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
// Разведка М2: отряд вышел на разведку с отчётом / отчёт готов (ТЗ разведки, раздел 10)
|
||||
public struct ReconStartedEvent
|
||||
{
|
||||
public string EventId; // "recon_<номер похода>"
|
||||
public string TargetId;
|
||||
public int Level;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ReconCompletedEvent
|
||||
{
|
||||
public string EventId; // = id отчёта
|
||||
public string TargetId;
|
||||
public int Level;
|
||||
public long TimeMs;
|
||||
}
|
||||
|
||||
public struct ArmyIdentifiedEvent // владелец чужой армии опознан
|
||||
{
|
||||
public string EventId;
|
||||
|
||||
@@ -22,6 +22,14 @@ public sealed class IntelligenceRules
|
||||
public long MaxCatchUpMs = 48L * 3_600_000; // офлайн досчитывается не больше чем за 48 часов
|
||||
public int CellSize = 1000; // клетка тумана = клетка карты
|
||||
|
||||
// М2, целенаправленная разведка (ТЗ, раздел 6): сколько разведчики наблюдают цель, ИГРОВЫХ часов (уровень II, III)
|
||||
public int[] ReconSettlementGameHours = { 2, 4 };
|
||||
public int[] ReconCastleGameHours = { 3, 6 };
|
||||
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||||
public int MaxReports = 30; // храним последние отчёты
|
||||
|
||||
public static IntelligenceRules Default => new IntelligenceRules();
|
||||
}
|
||||
|
||||
@@ -255,10 +263,105 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
get { int n = 0; foreach (var e in explored) if (e) n++; return n; }
|
||||
}
|
||||
|
||||
// ---------------- М2: целенаправленная разведка и отчёты ----------------
|
||||
|
||||
private readonly List<ReconReport> reports = new List<ReconReport>();
|
||||
public IReadOnlyList<ReconReport> Reports => reports;
|
||||
|
||||
public long ReconDurationMs(string targetId, int level)
|
||||
{
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null || level < 2 || level > 3 || !map.TryGetReconTarget(targetId, out ReconTarget t)) return 0;
|
||||
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
|
||||
: t.Kind == ReconTargetKind.Camp ? rules.ReconCampGameHours
|
||||
: t.Kind == ReconTargetKind.Settlement ? rules.ReconSettlementGameHours : null;
|
||||
if (hours == null || hours.Length < level - 1) return 0;
|
||||
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
|
||||
}
|
||||
|
||||
// Отчёт -- снимок цели на момент конца наблюдения. Один отчёт на операцию: повтор (загрузка, двойное событие) -- false
|
||||
public bool CompleteRecon(string reportId, string targetId, int level, long timeMs)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reportId) || reports.Exists(r => r.Id == reportId)) return false;
|
||||
IWorldMap map = worldMap();
|
||||
if (map == null || !map.TryGetReconTarget(targetId, out ReconTarget t)) return false;
|
||||
level = Math.Max(2, Math.Min(3, level));
|
||||
|
||||
var report = new ReconReport
|
||||
{
|
||||
Id = reportId, TargetId = targetId, TargetName = t.Name, Level = level, TimeMs = timeMs,
|
||||
Units = t.Units != null ? (int[])t.Units.Clone() : new int[4],
|
||||
Fortified = t.Fortified, HasHero = t.HasHero,
|
||||
Modifiers = "", ResourceNames = Array.Empty<string>(), ResourceMin = Array.Empty<int>(), ResourceMax = Array.Empty<int>()
|
||||
};
|
||||
if (level >= 3)
|
||||
{
|
||||
report.Modifiers = (t.Fortified ? "защитники за стенами" : "бой в поле, стен нет") + (t.HasHero ? ", есть герой" : ", героя нет");
|
||||
int n = t.ResourceNames != null ? t.ResourceNames.Length : 0;
|
||||
report.ResourceNames = new string[n];
|
||||
report.ResourceMin = new int[n];
|
||||
report.ResourceMax = new int[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int amount = t.ResourceAmounts != null && i < t.ResourceAmounts.Length ? Math.Max(0, t.ResourceAmounts[i]) : 0;
|
||||
int width = amount * 2 * rules.ResourceEstimatePercent / 100; // ±15% -> окно 30%, настоящее значение внутри
|
||||
int shift = width > 0 ? (int)(Roll(reportId, i, 3) % (ulong)(width + 1)) : 0;
|
||||
report.ResourceNames[i] = t.ResourceNames[i];
|
||||
report.ResourceMin[i] = Math.Max(0, amount - shift);
|
||||
report.ResourceMax[i] = report.ResourceMin[i] + width;
|
||||
}
|
||||
}
|
||||
reports.Add(report);
|
||||
while (reports.Count > Math.Max(1, rules.MaxReports)) reports.RemoveAt(0);
|
||||
EventBus.Publish(new ReconCompletedEvent { EventId = reportId, TargetId = targetId, Level = level, TimeMs = timeMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGetLatestReport(string targetId, out ReconReport report)
|
||||
{
|
||||
for (int i = reports.Count - 1; i >= 0; i--)
|
||||
if (reports[i].TargetId == targetId) { report = reports[i]; return true; }
|
||||
report = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
static string Csv(int[] v)
|
||||
{
|
||||
if (v == null) return "";
|
||||
var parts = new string[v.Length];
|
||||
for (int i = 0; i < v.Length; i++) parts[i] = v[i].ToString(CultureInfo.InvariantCulture);
|
||||
return string.Join(",", parts);
|
||||
}
|
||||
|
||||
static int[] ParseCsv(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return Array.Empty<int>();
|
||||
string[] parts = s.Split(',');
|
||||
var v = new int[parts.Length];
|
||||
for (int i = 0; i < parts.Length; i++) v[i] = I(parts[i]);
|
||||
return v;
|
||||
}
|
||||
|
||||
static string Clean(string s) => (s ?? "").Replace("|", "/").Replace("\n", " ");
|
||||
|
||||
void RestoreReport(string[] p)
|
||||
{
|
||||
if (p.Length < 12 || reports.Exists(r => r.Id == p[0])) return;
|
||||
long.TryParse(p[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out long time);
|
||||
reports.Add(new ReconReport
|
||||
{
|
||||
Id = p[0], TargetId = p[1], TargetName = p[2], Level = I(p[3]), TimeMs = time,
|
||||
Units = ParseCsv(p[5]), Fortified = p[6] == "1", HasHero = p[7] == "1", Modifiers = p[8],
|
||||
ResourceNames = string.IsNullOrEmpty(p[9]) ? Array.Empty<string>() : p[9].Split(','),
|
||||
ResourceMin = ParseCsv(p[10]), ResourceMax = ParseCsv(p[11])
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- Сохранение (секция "intelligence") ----------------
|
||||
// Простые строки (модуль без Unity):
|
||||
// tick=<мс> obzor=<уровень> grid=<ширина>x<высота>:<клетки 0/1 в hex>
|
||||
// contact=<армия>|<владелец>|<опознан>|<видна>|x|y|toX|toY|min|max|<видели, мс>|<номер наблюдения>
|
||||
// report=<id>|<цель>|<название>|<уровень>|<время, мс>|<войска,..>|<стены 1/0>|<герой 1/0>|<модификаторы>|<ресурсы,..>|<мин,..>|<макс,..>
|
||||
|
||||
public string SaveKey => "intelligence";
|
||||
|
||||
@@ -275,6 +378,13 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
N(c.x), N(c.y), N(c.toX), N(c.toY), N(c.approxMin), N(c.approxMax),
|
||||
c.lastSeenMs.ToString(CultureInfo.InvariantCulture), N(c.observation)
|
||||
})).Append('\n');
|
||||
foreach (var r in reports)
|
||||
sb.Append("report=").Append(string.Join("|", new[]
|
||||
{
|
||||
Clean(r.Id), Clean(r.TargetId), Clean(r.TargetName), N(r.Level), r.TimeMs.ToString(CultureInfo.InvariantCulture),
|
||||
Csv(r.Units), r.Fortified ? "1" : "0", r.HasHero ? "1" : "0", Clean(r.Modifiers),
|
||||
string.Join(",", r.ResourceNames ?? Array.Empty<string>()), Csv(r.ResourceMin), Csv(r.ResourceMax)
|
||||
})).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -289,6 +399,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
else if (line.StartsWith("obzor=") && int.TryParse(line.Substring(6), NumberStyles.Integer, CultureInfo.InvariantCulture, out int lvl)) ObservationLevel = lvl;
|
||||
else if (line.StartsWith("grid=")) RestoreGrid(line.Substring(5));
|
||||
else if (line.StartsWith("contact=")) RestoreContact(line.Substring(8).Split('|'));
|
||||
else if (line.StartsWith("report=")) RestoreReport(line.Substring(7).Split('|'));
|
||||
}
|
||||
viewDirty = true;
|
||||
}
|
||||
@@ -296,6 +407,7 @@ public sealed class IntelligenceService : IIntelligence, ISaveParticipant
|
||||
public void ResetState()
|
||||
{
|
||||
contacts.Clear();
|
||||
reports.Clear();
|
||||
explored = Array.Empty<bool>();
|
||||
gridW = gridH = halfW = halfH = 0;
|
||||
lastTickMs = 0;
|
||||
|
||||
@@ -1224,6 +1224,8 @@ public class ModuleTests
|
||||
return ownerId == LocalPlayer.Id;
|
||||
}
|
||||
public void GetArmies(long timeMs, List<ArmyOnMap> result) => result.AddRange(Armies(timeMs));
|
||||
public Dictionary<string, ReconTarget> Targets = new Dictionary<string, ReconTarget>();
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target) => Targets.TryGetValue(targetId ?? "", out target);
|
||||
}
|
||||
|
||||
static ArmyOnMap Foreign(string id, int x, int y, int soldiers = 50) =>
|
||||
@@ -1503,6 +1505,21 @@ public class ModuleTests
|
||||
x = home.x; y = home.y;
|
||||
return ownerId == LocalPlayer.Id;
|
||||
}
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
||||
{
|
||||
target = default;
|
||||
MapNode node = World.GetNode(targetId);
|
||||
if (node == null || node.type == MapNodeType.ResourcePoint || node.type == MapNodeType.PlayerCity) return false;
|
||||
target = new ReconTarget
|
||||
{
|
||||
Id = node.id, Name = node.displayName,
|
||||
Kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle : node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.Settlement,
|
||||
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node),
|
||||
ResourceNames = new[] { "Gold" }, ResourceAmounts = new[] { 100 }
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public void GetArmies(long t, List<ArmyOnMap> result)
|
||||
{
|
||||
foreach (var m in World.Marches)
|
||||
@@ -2076,4 +2093,93 @@ public class ModuleTests
|
||||
Assert.AreEqual(hurt, copy.WoundedTotal);
|
||||
Assert.IsFalse(copy.MarkMissing(1, new[] { 5, 0, 0, 0 }, 0, 0), "поход уже отмечен -- и после загрузки");
|
||||
}
|
||||
|
||||
// ---------------- Разведка М2: целенаправленная разведка и отчёты (ТЗ разведки, раздел 6) ----------------
|
||||
|
||||
static FakeMap ReconMap()
|
||||
{
|
||||
var map = new FakeMap();
|
||||
map.Targets["city"] = new ReconTarget { Id = "city", Name = "Ратибор", Kind = ReconTargetKind.Settlement,
|
||||
Units = new[] { 10, 5, 3, 0 }, Fortified = true, ResourceNames = new[] { "Gold", "Food" }, ResourceAmounts = new[] { 100, 40 } };
|
||||
map.Targets["castle"] = new ReconTarget { Id = "castle", Name = "Замок", Kind = ReconTargetKind.Castle, Units = new[] { 8, 6, 4, 2 }, Fortified = true };
|
||||
return map;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recon_DurationByTargetAndLevel()
|
||||
{
|
||||
var intel = Intel(ReconMap());
|
||||
const long H = 5 * 60_000;
|
||||
Assert.AreEqual(2 * H, intel.ReconDurationMs("city", 2), "поселение, уровень II -- 2 игровых часа");
|
||||
Assert.AreEqual(4 * H, intel.ReconDurationMs("city", 3));
|
||||
Assert.AreEqual(3 * H, intel.ReconDurationMs("castle", 2), "замок -- 3 / 6");
|
||||
Assert.AreEqual(6 * H, intel.ReconDurationMs("castle", 3));
|
||||
Assert.AreEqual(0, intel.ReconDurationMs("nothing", 2), "нет такой цели");
|
||||
Assert.AreEqual(0, intel.ReconDurationMs("city", 1), "уровень I -- без отчёта (это обычное наблюдение)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Recon_ReportLevels_OncePerOperation_Saved()
|
||||
{
|
||||
var map = ReconMap();
|
||||
var intel = Intel(map);
|
||||
var done = new List<ReconCompletedEvent>();
|
||||
EventBus.Subscribe<ReconCompletedEvent>(e => done.Add(e));
|
||||
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_1", "city", 2, 5000));
|
||||
Assert.IsFalse(intel.CompleteRecon("recon_1", "city", 2, 9000), "одна операция -- один отчёт");
|
||||
Assert.AreEqual(1, done.Count);
|
||||
Assert.IsTrue(intel.TryGetLatestReport("city", out var r2));
|
||||
Assert.AreEqual(new[] { 10, 5, 3, 0 }[0], r2.Units[0], "уровень II -- точные войска по типам");
|
||||
Assert.IsTrue(r2.Fortified);
|
||||
Assert.AreEqual(0, r2.ResourceNames.Length, "уровень II -- без ресурсов");
|
||||
Assert.AreEqual(5000, r2.TimeMs, "время отчёта -- конец наблюдения");
|
||||
|
||||
// Цель изменилась после отчёта -- старый отчёт не обновляется (нет постоянного слежения)
|
||||
var t = map.Targets["city"]; t.Units = new[] { 50, 0, 0, 0 }; map.Targets["city"] = t;
|
||||
Assert.AreEqual(10, intel.Reports[0].Units[0]);
|
||||
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_2", "city", 3, 7000));
|
||||
intel.TryGetLatestReport("city", out var r3);
|
||||
Assert.AreEqual(50, r3.Units[0], "новый отчёт -- на момент своей операции");
|
||||
Assert.AreEqual(2, r3.ResourceNames.Length, "уровень III -- оценка ресурсов");
|
||||
Assert.IsTrue(r3.ResourceMin[0] <= 100 && 100 <= r3.ResourceMax[0], "настоящее значение внутри окна");
|
||||
Assert.AreEqual(30, r3.ResourceMax[0] - r3.ResourceMin[0], "±15% от 100 = окно 30");
|
||||
StringAssertContains(r3.Modifiers, "стен");
|
||||
|
||||
var copy = Intel(map);
|
||||
copy.Restore(intel.Capture());
|
||||
Assert.AreEqual(intel.Capture(), copy.Capture(), "отчёты сохраняются");
|
||||
Assert.IsFalse(copy.CompleteRecon("recon_2", "city", 3, 7000), "после загрузки отчёт не создаётся второй раз");
|
||||
}
|
||||
|
||||
static void StringAssertContains(string text, string part) => Assert.IsTrue(text != null && text.Contains(part), $"«{text}» не содержит «{part}»");
|
||||
|
||||
[Test]
|
||||
public void World_ReconMarch_ObservesThenReturns()
|
||||
{
|
||||
const long H = 5 * 60_000;
|
||||
long t0 = 1_000_000_000_000;
|
||||
var world = new WorldSimulation(WorldMapLayout.CreateDefault(), new BattleSystem());
|
||||
var intel = Intel(new WorldAsMap { World = world });
|
||||
MarchData m = world.StartMarch(LocalPlayer.Id, WorldMapLayout.PlayerCityId, WorldMapLayout.BossCastleId, 5, t0, out _, scout: true);
|
||||
Assert.IsTrue(world.SetRecon(m, 3, intel.ReconDurationMs(WorldMapLayout.BossCastleId, 3)));
|
||||
world.AssignUnits(m, new[] { 5, 0, 0, 0 });
|
||||
|
||||
var events = new List<WorldEvent>();
|
||||
world.Advance(m.arriveMs, events);
|
||||
Assert.AreEqual(WorldEventType.ScoutArrived, events[0].Type);
|
||||
Assert.AreEqual(MarchState.Gathering, m.state, "стоят у цели и наблюдают");
|
||||
Assert.AreEqual(m.arriveMs + 6 * H, m.gatherEndMs, "замок, уровень III -- 6 игровых часов");
|
||||
Assert.IsTrue(world.TryGetPositionAt(m, m.arriveMs + H, out int x, out int y, out _, out _, out _));
|
||||
MapNode boss = world.GetNode(WorldMapLayout.BossCastleId);
|
||||
Assert.AreEqual(boss.x, x, "во время наблюдения -- у цели");
|
||||
|
||||
events.Clear();
|
||||
world.Advance(m.returnMs, events);
|
||||
Assert.AreEqual(WorldEventType.ReconCompleted, events[0].Type, "сначала -- отчёт");
|
||||
Assert.AreEqual(WorldEventType.MarchReturned, events[1].Type, "потом -- домой");
|
||||
Assert.AreEqual(5, m.survivors, "разведка без потерь");
|
||||
Assert.IsTrue(intel.CompleteRecon("recon_" + m.id, m.targetNodeId, m.reconLevel, m.gatherEndMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
public event Action<MarchData> OnBattleResolved;
|
||||
public event Action<MarchData> OnMarchReturned;
|
||||
public event Action<MarchData> OnGatherStarted; // дошли до ресурсной точки (targetEmpty -- точки уже нет)
|
||||
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой
|
||||
public event Action<MarchData> OnScoutArrived; // разведка дошла до цели и повернула домой (или начала наблюдение)
|
||||
public event Action<MarchData> OnReconCompleted; // Разведка М2: наблюдение закончено, отчёт готов
|
||||
|
||||
// Время мира -- по часам игры (GameClock): перемотка "+1 час" в отладочной панели проматывает и походы.
|
||||
// Миллисекунды берём от настоящих часов, чтобы отряды на карте двигались плавно. В мультиплеере -- время сервера.
|
||||
@@ -113,6 +114,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
else if (e.Type == WorldEventType.MarchReturned) HandleMarchReturned(e.March);
|
||||
else if (e.Type == WorldEventType.GatherStarted) HandleGatherStarted(e.March);
|
||||
else if (e.Type == WorldEventType.ScoutArrived) HandleScoutArrived(e.March);
|
||||
else if (e.Type == WorldEventType.ReconCompleted) HandleReconCompleted(e.March);
|
||||
else if (e.Type == WorldEventType.GatherFinished)
|
||||
Debug.Log($"[СБОР] Сбор окончен: {e.March.cargo} ({World.TargetName(e.March)}), войско идёт домой");
|
||||
}
|
||||
@@ -173,6 +175,28 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
return false;
|
||||
}
|
||||
|
||||
// Цель разведки с отчётом (М2): замок, соседний город, лагерь -- гарнизон, стены, что можно взять
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
||||
{
|
||||
target = default;
|
||||
MapNode node = World.GetNode(targetId);
|
||||
if (node == null) return false;
|
||||
ReconTargetKind kind = node.type == MapNodeType.BossCastle ? ReconTargetKind.Castle
|
||||
: node.type == MapNodeType.NeighborCity ? ReconTargetKind.Settlement
|
||||
: node.type == MapNodeType.Camp ? ReconTargetKind.Camp : ReconTargetKind.None;
|
||||
if (kind == ReconTargetKind.None) return false;
|
||||
var names = new string[node.rewards.Count];
|
||||
var amounts = new int[node.rewards.Count];
|
||||
for (int i = 0; i < node.rewards.Count; i++) { names[i] = node.rewards[i].resource; amounts[i] = node.rewards[i].amount; }
|
||||
target = new ReconTarget
|
||||
{
|
||||
Id = node.id, Name = node.displayName, Kind = kind,
|
||||
Units = WorldMapLayout.GarrisonUnits(node), Fortified = WorldMapLayout.IsFortified(node), HasHero = false, // героев пока нет
|
||||
ResourceNames = names, ResourceAmounts = amounts
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Все армии на карте в момент timeMs: свои походы, патрули соседей (позже -- армии других игроков с сервера)
|
||||
public void GetArmies(long timeMs, List<ArmyOnMap> result)
|
||||
{
|
||||
@@ -194,7 +218,17 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
return TryStart(targetId, soldiers, true, out error);
|
||||
}
|
||||
|
||||
bool TryStart(string targetId, int soldiers, bool scout, out string error)
|
||||
// Разведка с отчётом (Разведка М2): уровень II или III, наблюдение у цели -- время уровня из модуля Разведки
|
||||
public bool TryStartRecon(string targetId, int soldiers, int level, out string error)
|
||||
{
|
||||
if (level < 2 || level > 3) { error = "Уровень разведки -- II или III"; return false; }
|
||||
if (!GameServices.TryGet(out IIntelligence intel)) { error = "Модуль Разведки не подключён"; return false; }
|
||||
long hold = intel.ReconDurationMs(targetId, level);
|
||||
if (hold <= 0) { error = "Разведку с отчётом можно отправить на поселение, замок или лагерь"; return false; }
|
||||
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)
|
||||
{
|
||||
if (!GameServices.TryGet(out IArmy army))
|
||||
{
|
||||
@@ -218,6 +252,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
|
||||
MarchData march = World.StartMarch(LocalPlayerId, WorldMapLayout.PlayerCityId, targetId, soldiers, NowMs, out error, scout);
|
||||
if (march == null) return false;
|
||||
if (reconLevel > 0) World.SetRecon(march, reconLevel, reconHoldMs);
|
||||
|
||||
// Отправка целиком или никак: поход существует, только если Армия действительно забрала воинов.
|
||||
// Иначе поход откатывается -- он не должен вернуть домой воинов, которых не забирал.
|
||||
@@ -234,9 +269,22 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
Debug.Log($"[ПОХОД] {soldiers} солдат выступили {(scout ? "в разведку" : "на")} {World.GetNode(targetId).displayName}");
|
||||
OnMarchStarted?.Invoke(march);
|
||||
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
|
||||
if (reconLevel > 0)
|
||||
EventBus.Publish(new ReconStartedEvent { EventId = ReportId(march), TargetId = targetId, Level = reconLevel, TimeMs = march.departMs });
|
||||
return true;
|
||||
}
|
||||
|
||||
static string ReportId(MarchData march) => "recon_" + march.id;
|
||||
|
||||
// Наблюдение закончено: Разведка составляет отчёт по цели на этот момент (один раз на поход)
|
||||
void HandleReconCompleted(MarchData march)
|
||||
{
|
||||
if (GameServices.TryGet(out IIntelligence intel))
|
||||
intel.CompleteRecon(ReportId(march), march.targetNodeId, march.reconLevel, march.gatherEndMs);
|
||||
Debug.Log($"[РАЗВЕДКА] Отчёт уровня {march.reconLevel}: {World.TargetName(march)}");
|
||||
OnReconCompleted?.Invoke(march);
|
||||
}
|
||||
|
||||
void HandleBattleResolved(MarchData march)
|
||||
{
|
||||
// Пропавшие без вести (решения 01.11, п. 5): уходят из похода в Армию отдельной группой -- вернутся сами
|
||||
|
||||
@@ -169,7 +169,7 @@ namespace Bogatyri.World
|
||||
return true;
|
||||
}
|
||||
|
||||
long backStart = m.gather ? m.gatherEndMs : m.arriveMs;
|
||||
long backStart = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения
|
||||
soldiers = m.state == MarchState.Gathering || m.gather && t < backStart ? m.soldiers : m.survivors;
|
||||
if (soldiers <= 0) return false; // все пали в бою -- армии на карте нет
|
||||
if (m.state == MarchState.Gathering || t < backStart)
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace Bogatyri.World
|
||||
public bool gather; // поход на ресурсную точку: сбор вместо боя
|
||||
public bool patrol; // патруль-заглушка соседа (Разведка М1): без боя и добычи, Армии игрока не касается
|
||||
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
|
||||
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
|
||||
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
|
||||
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
|
||||
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
@@ -147,7 +149,8 @@ namespace Bogatyri.World
|
||||
MarchReturned, // войско вернулось домой
|
||||
GatherStarted, // войско дошло до ресурсной точки и начало сбор (или точка исчезла -- targetEmpty)
|
||||
GatherFinished, // сбор закончен, войско идёт домой с грузом
|
||||
ScoutArrived // разведка дошла до цели и повернула домой (targetEmpty -- цели уже нет)
|
||||
ScoutArrived, // разведка дошла до цели: повернула домой или (М2) начала наблюдение (targetEmpty -- цели уже нет)
|
||||
ReconCompleted // М2: наблюдение закончено -- пора составить отчёт, разведчики идут домой
|
||||
}
|
||||
|
||||
public struct WorldEvent
|
||||
@@ -373,6 +376,11 @@ namespace Bogatyri.World
|
||||
ArriveScout(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else if (nextKind == 1)
|
||||
{
|
||||
next.state = MarchState.Returning; // наблюдение закончено, обратный путь уже рассчитан
|
||||
events.Add(new WorldEvent { Type = WorldEventType.ReconCompleted, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else
|
||||
{
|
||||
next.state = MarchState.Finished;
|
||||
@@ -487,6 +495,23 @@ namespace Bogatyri.World
|
||||
m.loot = new List<RewardEntry>();
|
||||
ResourcePoint p = target.type == MapNodeType.ResourcePoint ? GetPoint(target.id) : null;
|
||||
m.targetEmpty = target.type == MapNodeType.ResourcePoint && (p == null || !p.active);
|
||||
|
||||
// М2: разведчики стоят у цели время наблюдения, потом идут домой (обратный путь -- от конца наблюдения)
|
||||
if (m.reconLevel > 0 && m.reconHoldMs > 0 && !m.targetEmpty)
|
||||
{
|
||||
m.state = MarchState.Gathering;
|
||||
m.gatherEndMs = m.arriveMs + m.reconHoldMs;
|
||||
m.returnMs = m.gatherEndMs + TravelTimeMs(target, from, units);
|
||||
}
|
||||
}
|
||||
|
||||
// Разведка с отчётом (М2): уровень и время наблюдения. Только пока разведчики ещё в пути к цели
|
||||
public bool SetRecon(MarchData m, int level, long holdMs)
|
||||
{
|
||||
if (m == null || !m.scout || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
|
||||
m.reconLevel = level;
|
||||
m.reconHoldMs = Math.Max(0, holdMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Название цели похода: запомненное при выступлении (старые походы -- с карты)
|
||||
@@ -542,7 +567,7 @@ namespace Bogatyri.World
|
||||
{
|
||||
segmentFrom = target;
|
||||
segmentTo = home;
|
||||
start = m.gather ? m.gatherEndMs : m.arriveMs; // после сбора -- обратно от конца сбора
|
||||
start = m.gather || m.reconLevel > 0 && m.gatherEndMs > 0 ? m.gatherEndMs : m.arriveMs; // после сбора / наблюдения -- от его конца
|
||||
end = m.returnMs;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ public static class ArmiesWindow
|
||||
timer = $"до цели {Clock(m.arriveMs - now)}, вернётся примерно через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
case MarchState.Gathering:
|
||||
action = $"собирает: <b>{target}</b>";
|
||||
action = m.scout ? $"наблюдает за: <b>{target}</b> (разведка {(m.reconLevel == 3 ? "III" : "II")})" : $"собирает: <b>{target}</b>";
|
||||
timer = $"сбор ещё {Clock(m.gatherEndMs - now)}, вернётся через {Clock(ExpectedReturn(m) - now)}";
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -125,7 +125,7 @@ public class MarchView : MonoBehaviour
|
||||
long endMs = returning ? march.returnMs : gathering ? march.gatherEndMs : march.arriveMs;
|
||||
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
|
||||
string direction = returning ? (march.cargo > 0 ? $"домой, груз {march.cargo}" : "домой")
|
||||
: gathering ? $"сбор {march.cargo}"
|
||||
: gathering ? (march.scout ? "наблюдает" : $"сбор {march.cargo}")
|
||||
: march.scout ? "разведка" : march.gather ? "на сбор" : "в бой";
|
||||
// Марши идут часами (регламент 31.10) -- «1:05:30», меньше часа -- «12:30»
|
||||
string time = secondsLeft >= 3600
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
// Отчёты разведки на экране (Разведка М2): текст отчёта и окно «Отчёты» с последними отчётами.
|
||||
// Сами отчёты хранит модуль Разведки (IIntelligence.Reports) -- здесь только показ.
|
||||
// Сведения -- на момент отчёта: сколько прошло с тех пор, видно в каждом отчёте.
|
||||
public static class ReconReportText
|
||||
{
|
||||
static readonly string[] TroopNames = { "пехота", "лучники", "кавалерия", "осадные" };
|
||||
static readonly Dictionary<string, string> ResourceNames = new Dictionary<string, string>
|
||||
{
|
||||
{ "Food", "еда" }, { "Wood", "дерево" }, { "Stone", "камень" }, { "Iron", "металл" }, { "Gold", "золото" }, { "Gems", "кристаллы" }
|
||||
};
|
||||
|
||||
public static string Format(ReconReport r, long nowMs)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"<b>{r.TargetName}</b> — разведка {(r.Level == 3 ? "III" : "II")}, {Ago(nowMs - r.TimeMs)} назад",
|
||||
};
|
||||
int total = 0;
|
||||
var parts = new List<string>();
|
||||
for (int i = 0; r.Units != null && i < r.Units.Length; i++)
|
||||
{
|
||||
total += r.Units[i];
|
||||
if (r.Units[i] > 0) parts.Add($"{TroopNames[i % TroopNames.Length]} {r.Units[i]}");
|
||||
}
|
||||
lines.Add($"Гарнизон: {total}" + (parts.Count > 0 ? $" ({string.Join(", ", parts)})" : ""));
|
||||
lines.Add((r.Fortified ? "Защитники за стенами" : "Стен нет, бой в поле") + (r.HasHero ? ", есть герой" : ", героя нет"));
|
||||
if (r.Level >= 3)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(r.Modifiers)) lines.Add("Защита: " + r.Modifiers);
|
||||
var res = new List<string>();
|
||||
for (int i = 0; r.ResourceNames != null && i < r.ResourceNames.Length; i++)
|
||||
{
|
||||
string name = ResourceNames.TryGetValue(r.ResourceNames[i], out var n) ? n : r.ResourceNames[i];
|
||||
res.Add($"{name} ~{r.ResourceMin[i]}–{r.ResourceMax[i]}");
|
||||
}
|
||||
if (res.Count > 0) lines.Add("Ресурсы (оценка ±15%): " + string.Join(", ", res));
|
||||
}
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
static string Ago(long ms)
|
||||
{
|
||||
long min = System.Math.Max(0, ms) / 60_000;
|
||||
if (min < 1) return "только что";
|
||||
return min < 60 ? $"{min} мин" : $"{min / 60} ч {min % 60} мин";
|
||||
}
|
||||
|
||||
// Окно «Отчёты»: последние отчёты, новые сверху
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var card = UIKit.Modal("ОТЧЁТЫ РАЗВЕДКИ", out GameObject root);
|
||||
if (!GameServices.TryGet(out IIntelligence intel) || intel.Reports.Count == 0)
|
||||
{
|
||||
UIKit.Text(card, "Отчётов пока нет. Нажмите на город, лагерь или замок и отправьте «Разведку II» или «Разведку III».",
|
||||
26, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
else
|
||||
{
|
||||
long now = MarchManager.NowMs;
|
||||
int shown = 0;
|
||||
for (int i = intel.Reports.Count - 1; i >= 0 && shown < 5; i--, shown++)
|
||||
UIKit.Text(card, Format(intel.Reports[i], now), 24, UIKit.Theme.Text, TextAlignmentOptions.Left);
|
||||
if (intel.Reports.Count > 5)
|
||||
UIKit.Text(card, $"И ещё {intel.Reports.Count - 5} более старых", 22, UIKit.Theme.TextDim, TextAlignmentOptions.Center);
|
||||
}
|
||||
UIKit.Button(card, "Закрыть", UIKit.Theme.Gray, () => Object.Destroy(root), -1, 80, 28);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76599837523e444e87459016c4f367a4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -90,6 +90,7 @@ public class WorldMapController : MonoBehaviour
|
||||
private string attackInfoBase;
|
||||
private BattleCondition shownConditions;
|
||||
private bool shownObserved; // цель была в зоне обзора, когда окно атаки открылось
|
||||
private ReconReport? shownReport; // цель вне обзора, но есть отчёт разведки -- гарнизон и прогноз по нему (на момент отчёта)
|
||||
|
||||
WorldSimulation World => MarchManager.Instance.World;
|
||||
|
||||
@@ -129,6 +130,7 @@ public class WorldMapController : MonoBehaviour
|
||||
MarchManager.Instance.OnBattleResolved += HandleBattleResolved;
|
||||
MarchManager.Instance.OnGatherStarted += HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived += HandleScoutArrived;
|
||||
MarchManager.Instance.OnReconCompleted += HandleReconCompleted;
|
||||
MarchManager.Instance.OnMarchReturned += HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ public class WorldMapController : MonoBehaviour
|
||||
MarchManager.Instance.OnBattleResolved -= HandleBattleResolved;
|
||||
MarchManager.Instance.OnGatherStarted -= HandleGatherStarted;
|
||||
MarchManager.Instance.OnScoutArrived -= HandleScoutArrived;
|
||||
MarchManager.Instance.OnReconCompleted -= HandleReconCompleted;
|
||||
MarchManager.Instance.OnMarchReturned -= HandleMarchReturned;
|
||||
}
|
||||
|
||||
@@ -169,39 +172,68 @@ public class WorldMapController : MonoBehaviour
|
||||
var button = copy.GetComponent<Button>();
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(ArmiesWindow.Open);
|
||||
|
||||
// «Отчёты» -- под «Армиями»: последние отчёты разведки (Разведка М2)
|
||||
var reports = Instantiate(ratingButton.gameObject, ratingButton.transform.parent);
|
||||
reports.name = "ReportsButton";
|
||||
var rrt = (RectTransform)reports.transform;
|
||||
rrt.anchoredPosition = src.anchoredPosition - new Vector2(0, (src.rect.height + 12) * 2);
|
||||
var rl = reports.GetComponentInChildren<TMP_Text>();
|
||||
if (rl != null) rl.text = "Отчёты";
|
||||
var rb = reports.GetComponent<Button>();
|
||||
rb.onClick.RemoveAllListeners();
|
||||
rb.onClick.AddListener(ReconReportText.OpenWindow);
|
||||
}
|
||||
|
||||
// Кнопка «Разведка» в окне атаки (решения 31.10, п. 2) -- копия «Отправить». Три кнопки в ряд: Отмена, Разведка, Отправить
|
||||
private Button scoutButton;
|
||||
// Кнопки разведки в окне атаки -- копии «Отправить». Четыре в ряд: Отмена, Разведка II, Разведка III, Отправить.
|
||||
// Разведка II / III (Разведка М2): наблюдать цель и принести отчёт; у ресурсной точки -- просто «Разведка» (дойти и вернуться)
|
||||
private Button scoutButton, scout3Button;
|
||||
private TMP_Text scoutLabel;
|
||||
|
||||
void CreateScoutButton()
|
||||
{
|
||||
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
|
||||
copy.name = "ScoutButton";
|
||||
scoutButton = copy.GetComponent<Button>();
|
||||
scoutButton.onClick.RemoveAllListeners();
|
||||
scoutButton.onClick.AddListener(OnScoutPressed);
|
||||
var label = copy.GetComponentInChildren<TMP_Text>();
|
||||
if (label != null) label.text = "Разведка";
|
||||
scoutButton = CopyOfSend("ScoutButton", "Разведка II", () => OnScoutPressed(2));
|
||||
scoutLabel = scoutButton.GetComponentInChildren<TMP_Text>();
|
||||
scout3Button = CopyOfSend("Scout3Button", "Разведка III", () => OnScoutPressed(3));
|
||||
|
||||
Place(cancelButton, -290f);
|
||||
Place(scoutButton, 0f);
|
||||
Place(sendButton, 290f);
|
||||
Place(cancelButton, -330f);
|
||||
Place(scoutButton, -110f);
|
||||
Place(scout3Button, 110f);
|
||||
Place(sendButton, 330f);
|
||||
}
|
||||
|
||||
Button CopyOfSend(string name, string text, UnityEngine.Events.UnityAction onClick)
|
||||
{
|
||||
var copy = Instantiate(sendButton.gameObject, sendButton.transform.parent);
|
||||
copy.name = name;
|
||||
var b = copy.GetComponent<Button>();
|
||||
b.onClick.RemoveAllListeners();
|
||||
b.onClick.AddListener(onClick);
|
||||
var label = copy.GetComponentInChildren<TMP_Text>();
|
||||
if (label != null) label.text = text;
|
||||
return b;
|
||||
}
|
||||
|
||||
static void Place(Button button, float x)
|
||||
{
|
||||
var rt = (RectTransform)button.transform;
|
||||
rt.anchoredPosition = new Vector2(x, rt.anchoredPosition.y);
|
||||
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 260f), rt.sizeDelta.y);
|
||||
rt.sizeDelta = new Vector2(Mathf.Min(rt.sizeDelta.x, 205f), rt.sizeDelta.y);
|
||||
}
|
||||
|
||||
void OnScoutPressed()
|
||||
void OnScoutPressed(int level)
|
||||
{
|
||||
if (selectedTarget == null) return;
|
||||
int soldiers = (int)soldiersSlider.value;
|
||||
hintText.text = MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out string error)
|
||||
? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
|
||||
bool point = selectedTarget.type == MapNodeType.ResourcePoint;
|
||||
string error;
|
||||
bool ok = point
|
||||
? MarchManager.Instance.TryStartScout(selectedTarget.id, soldiers, out error)
|
||||
: MarchManager.Instance.TryStartRecon(selectedTarget.id, soldiers, level, out error);
|
||||
long hold = !point && GameServices.TryGet(out IIntelligence intel) ? intel.ReconDurationMs(selectedTarget.id, level) : 0;
|
||||
hintText.text = ok
|
||||
? point ? $"Разведка выступила: {soldiers} воинов идут к цели «{selectedTarget.displayName}» и вернутся без боя"
|
||||
: $"Разведка {(level == 3 ? "III" : "II")} выступила к «{selectedTarget.displayName}»: наблюдать {Duration(hold)}, потом отчёт и домой"
|
||||
: "Разведка не начата: " + error;
|
||||
CloseAttackPanel();
|
||||
}
|
||||
@@ -518,10 +550,14 @@ public class WorldMapController : MonoBehaviour
|
||||
attackInfoText.fontSizeMin = 18;
|
||||
attackInfoText.fontSizeMax = 32;
|
||||
shownObserved = IsObserved(target);
|
||||
shownReport = !shownObserved && GameServices.TryGet(out IIntelligence intelReports) && intelReports.TryGetLatestReport(target.id, out var report)
|
||||
? report : (ReconReport?)null;
|
||||
attackInfoBase = target.type == MapNodeType.ResourcePoint ? PointInfo(target)
|
||||
: (shownObserved
|
||||
? $"Гарнизон: {target.garrison} ({UnitsText(WorldMapLayout.GarrisonUnits(target))})"
|
||||
: "Гарнизон неизвестен: цель вне зоны обзора") +
|
||||
: shownReport.HasValue
|
||||
? $"Гарнизон по отчёту разведки {Duration(MarchManager.NowMs - shownReport.Value.TimeMs)} назад: {Sum(shownReport.Value.Units)} ({UnitsText(shownReport.Value.Units)})"
|
||||
: "Гарнизон неизвестен: цель вне зоны обзора. Отправьте разведку") +
|
||||
(WorldMapLayout.IsFortified(target) ? ", защитники за стенами\n" : ", бой в поле\n") +
|
||||
$"Награда за победу: {MarchManager.FormatLoot(target.rewards)}";
|
||||
RefreshAttackConditions();
|
||||
@@ -533,7 +569,14 @@ public class WorldMapController : MonoBehaviour
|
||||
soldiersSlider.interactable = available > 0;
|
||||
bool slotFree = !GameServices.TryGet(out IArmy slotsArmy) || slotsArmy.ActiveMarches < slotsArmy.MarchSlots;
|
||||
sendButton.interactable = available > 0 && slotFree;
|
||||
bool pointTarget = target.type == MapNodeType.ResourcePoint;
|
||||
if (scoutButton != null) scoutButton.interactable = available > 0 && slotFree;
|
||||
if (scoutLabel != null) scoutLabel.text = pointTarget ? "Разведка" : "Разведка II";
|
||||
if (scout3Button != null)
|
||||
{
|
||||
scout3Button.gameObject.SetActive(!pointTarget); // у точки отчёта нет -- только дойти и вернуться
|
||||
scout3Button.interactable = available > 0 && slotFree;
|
||||
}
|
||||
UpdateSoldiersToSendText();
|
||||
RefreshAttackConditions(); // путь -- по составу при выбранном числе воинов
|
||||
|
||||
@@ -642,14 +685,15 @@ public class WorldMapController : MonoBehaviour
|
||||
if (selectedTarget.type == MapNodeType.ResourcePoint) return GatherPreview(soldiers);
|
||||
int[] units = PreviewUnits(soldiers); // гарнизон в поход не идёт
|
||||
if (units == null) return "";
|
||||
if (!shownObserved) return "прогноз боя недоступен: гарнизон цели неизвестен";
|
||||
if (!shownObserved && !shownReport.HasValue) return "прогноз боя недоступен: гарнизон цели неизвестен";
|
||||
int[] defenders = shownObserved ? WorldMapLayout.GarrisonUnits(selectedTarget) : shownReport.Value.Units; // по отчёту -- на момент отчёта
|
||||
|
||||
// Прогноз с учётом случайности ±5%: показывает риск, а не итог конкретного боя (план от 27.10.2026)
|
||||
BattleForecast f = GameServices.Get<IBattleSystem>().Forecast(new BattleRequest
|
||||
{
|
||||
AttackerUnits = units,
|
||||
DefenderUnits = WorldMapLayout.GarrisonUnits(selectedTarget),
|
||||
Defenders = selectedTarget.garrison,
|
||||
DefenderUnits = defenders,
|
||||
Defenders = Sum(defenders),
|
||||
DefenderFortified = WorldMapLayout.IsFortified(selectedTarget),
|
||||
AttackerConditions = MarchManager.PlayerConditions() // голод сейчас; в бою проверится ещё раз
|
||||
});
|
||||
@@ -661,6 +705,13 @@ public class WorldMapController : MonoBehaviour
|
||||
$"{chance}, шанс победы {ChanceText(f.WinChancePermille)}, потери {Range(f.AttackerLossesMin * 100L, f.AttackerLossesMax * 100L)}";
|
||||
}
|
||||
|
||||
static int Sum(int[] units)
|
||||
{
|
||||
int n = 0;
|
||||
if (units != null) foreach (var u in units) n += u;
|
||||
return n;
|
||||
}
|
||||
|
||||
// 273 десятых процента -> "27%"; около краёв -- с десятыми, чтобы 99,6% не выглядело как 100%
|
||||
static string ChanceText(int permille)
|
||||
{
|
||||
@@ -788,8 +839,19 @@ public class WorldMapController : MonoBehaviour
|
||||
EnqueuePopup(title, body);
|
||||
}
|
||||
|
||||
void HandleReconCompleted(MarchData march)
|
||||
{
|
||||
if (GameServices.TryGet(out IIntelligence intel) && intel.TryGetLatestReport(march.targetNodeId, out var report))
|
||||
EnqueuePopup("ОТЧЁТ РАЗВЕДКИ", ReconReportText.Format(report, MarchManager.NowMs) + "\nРазведчики возвращаются домой");
|
||||
}
|
||||
|
||||
void HandleScoutArrived(MarchData march)
|
||||
{
|
||||
if (march.reconLevel > 0 && !march.targetEmpty)
|
||||
{
|
||||
hintText.text = $"Разведка у цели «{World.TargetName(march)}»: наблюдает {Duration(march.gatherEndMs - march.arriveMs)}";
|
||||
return;
|
||||
}
|
||||
string target = World.TargetName(march);
|
||||
hintText.text = march.targetEmpty
|
||||
? $"Разведка: «{target}» уже нет, войско возвращается"
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Разведка М2: `ReconTargetKind`, `ReconTarget`, `ReconReport`; `IWorldMap.TryGetReconTarget`; `IIntelligence.ReconDurationMs/CompleteRecon/Reports/TryGetLatestReport`; `IWorld.TryStartRecon`; события `ReconStartedEvent`, `ReconCompletedEvent`. |
|
||||
| 04.11.2026 | Claude | Пропавшие без вести: `MissingInfo`, `IArmy.MissingTotal/Missing/MarkMissing/ProcessMissing`, `BattleOutcome.*MissingUnits`, события `MissingReturnedEvent`, `MissingWrittenOffEvent`, поля `Missing` у `TroopsChangedEvent` и `MarchReturnedEvent`. |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `IHospital`, `HealingInfo`; `IArmy`: `WoundedTotal`, `GetWoundedWaiting`, `Healing`, `HospitalBeds`, `ReturnUnits(…, woundedUnits, nowMs)`, `ProcessHealing`; события `SoldiersHealedEvent`, `TroopsChangedEvent.Wounded`, `MarchReturnedEvent.Wounded`. |
|
||||
| 01.11.2026 | Claude | `IArmy`: очередь обучения (`Training`, `CanQueueTraining`, `TryQueueTraining`, `ProcessTraining`, `FinishAllTraining`), `TrainingOrderInfo`, событие `TroopsTrainedEvent`; `GameClock.UnixNowMs` (решения 31.10, п. 8). |
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
|
||||
**Слушает:** ничего напрямую. `WorldAdvancingEvent` (публикует Мир перед каждым расчётом) связан с `ObserveUntil` в `App/GameModules`.
|
||||
|
||||
## М2: целенаправленная разведка и отчёты (сделано 04.11.2026)
|
||||
|
||||
ТЗ разведки, раздел 6. Отряд физически идёт к цели маршем Мира, стоит и наблюдает, отчёт фиксируется на конец наблюдения, затем отряд идёт домой. Постоянного слежения нет: старый отчёт не обновляется.
|
||||
|
||||
| Цель | Уровень II | Уровень III |
|
||||
| --- | --- | --- |
|
||||
| Поселение (соседний город) | 2 игровых часа | 4 |
|
||||
| Замок | 3 | 6 |
|
||||
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
|
||||
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | |
|
||||
|
||||
1 игровой час = 5 реальных минут.
|
||||
|
||||
- **Уровень I** — обычное наблюдение (М1): примерная численность в зоне обзора.
|
||||
- **Уровень II** — точное число и типы войск, есть ли герой, стены.
|
||||
- **Уровень III** — то же плюс защита (стены, герой) и ресурсы цели: оценка ±15% (окно, в котором точно лежит настоящее значение; бросок — по id отчёта).
|
||||
- Время наблюдения даёт Разведка (`ReconDurationMs`), Мир держит отряд у цели (`MarchData.reconLevel`, `reconHoldMs`, состояние «наблюдает») и сообщает `WorldEventType.ReconCompleted`; Разведка составляет отчёт по снимку цели от Мира (`IWorldMap.TryGetReconTarget`) — **один раз на операцию** (id `recon_<номер похода>`), повтор после загрузки ничего не создаёт.
|
||||
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
|
||||
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
|
||||
|
||||
**На карте:** в окне атаки — «Разведка II» и «Разведка III» (у ресурсной точки — просто «Разведка»: дойти и вернуться). Отряд у цели подписан «наблюдает». По окончании — окно «ОТЧЁТ РАЗВЕДКИ». Кнопка «Отчёты» под «Армиями» — последние отчёты. Если цель вне обзора, но есть отчёт, окно атаки показывает гарнизон «по отчёту N мин назад» и прогноз боя по нему.
|
||||
|
||||
## Патрули-заглушки (временно)
|
||||
|
||||
Решение команды 31.10.2026: для проверки М1 соседние поселения иногда отправляют тестовые отряды к другим городам и лагерям. **Это временная тестовая механика, а не ИИ соседей.**
|
||||
@@ -110,6 +132,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | М2: целенаправленная разведка поселений, замка и лагерей с отчётами уровней II и III (`ReconDurationMs`, `CompleteRecon`, `Reports`, `TryGetLatestReport`, `ReconStartedEvent` / `ReconCompletedEvent`, сохранение `report=`); на карте — кнопки «Разведка II/III», окно отчёта, «Отчёты», прогноз по отчёту. Разведка армий — позже. |
|
||||
| 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`). |
|
||||
|
||||
@@ -105,6 +105,7 @@ EditMode-тесты: `Assets/Scripts/Modules/Tests/ModuleTests.cs`. Запуск
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 04.11.2026 | Claude | Разведка М2: разведка поселений, замка и лагерей с отчётами II и III. Обновлены Intelligence, World, Core. |
|
||||
| 04.11.2026 | Claude | Пропавшие без вести (решения 01.11, п. 5; доля не утверждена — выключено). Обновлены Army, Battle, Population, World, Core. |
|
||||
| 04.11.2026 | Claude | Еда воинов и раненых по типам (решения 01.11, п. 4). Обновлены Population, Army. |
|
||||
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах. Обновлены World, Intelligence. |
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
|
||||
| Дата | Кто | Что |
|
||||
| --- | --- | --- |
|
||||
| 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 | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |
|
||||
| 01.11.2026 | Claude | Раненые (решения 31.10, п. 7): `MarchData.woundedUnits` — идут домой с войском, при возвращении Армия кладёт их в госпиталь; `MarchReturnedEvent.Wounded`; итог боя на карте показывает раненых. |
|
||||
|
||||
Reference in New Issue
Block a user