Feature/hero 3d #21
@@ -231,7 +231,7 @@ public struct ArmyOnMap
|
||||
}
|
||||
|
||||
// Цель разведки (ТЗ разведки, раздел 6): что за объект -- от этого время наблюдения
|
||||
public enum ReconTargetKind { None, Settlement, Castle, Camp }
|
||||
public enum ReconTargetKind { None, Settlement, Castle, Camp, Army } // Army -- движущаяся армия (id "march_<номер>")
|
||||
|
||||
// Снимок цели разведки от Мира -- то, что увидят разведчики в момент отчёта
|
||||
public struct ReconTarget
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class IntelligenceRules
|
||||
public int[] ReconSettlementGameHours = { 2, 4 };
|
||||
public int[] ReconCastleGameHours = { 3, 6 };
|
||||
public int[] ReconCampGameHours = { 2, 4 }; // лагеря в ТЗ не названы -- как поселение (ТЕСТ)
|
||||
public int[] ReconArmyGameHours = { 1, 2 }; // вражеская армия (ТЗ, раздел 6): II -- 1, III -- 2 игровых часа
|
||||
public long GameHourMs = 5 * 60 * 1000; // 1 игровой час = 5 реальных минут
|
||||
public int ResourceEstimatePercent = 15; // уровень III: ресурсы -- оценочный диапазон ±15%
|
||||
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;
|
||||
int[] hours = t.Kind == ReconTargetKind.Castle ? rules.ReconCastleGameHours
|
||||
: 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;
|
||||
return Math.Max(0, hours[level - 2]) * rules.GameHourMs;
|
||||
}
|
||||
|
||||
@@ -2182,4 +2182,104 @@ public class ModuleTests
|
||||
Assert.AreEqual(5, m.survivors, "разведка без потерь");
|
||||
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), "вернулись");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,21 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
public bool TryGetReconTarget(string targetId, out ReconTarget target)
|
||||
{
|
||||
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);
|
||||
if (node == null) return false;
|
||||
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);
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -250,9 +297,11 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
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 (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));
|
||||
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);
|
||||
EventBus.Publish(new MarchStartedEvent { MarchId = march.id, TimeMs = march.departMs, TargetId = targetId, Soldiers = soldiers, IsScout = scout });
|
||||
if (reconLevel > 0)
|
||||
@@ -280,7 +329,7 @@ public class MarchManager : MonoBehaviour, IWorld, IWorldMap, ISaveParticipant
|
||||
void HandleReconCompleted(MarchData march)
|
||||
{
|
||||
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)}");
|
||||
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)
|
||||
{
|
||||
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);
|
||||
if (home == null || target == null || t < m.departMs) return false;
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ namespace Bogatyri.World
|
||||
public bool scout; // поход «Разведка» (решения 31.10, п. 2): дойти до цели и вернуться, без боя и сбора
|
||||
public int reconLevel; // Разведка М2: 0 -- просто дойти и вернуться; 2 / 3 -- наблюдать reconHoldMs и составить отчёт
|
||||
public long reconHoldMs; // сколько наблюдать цель (мс часов игры): время уровня из модуля Разведки
|
||||
public int targetMarchId; // Разведка М2: цель -- движущаяся армия (номер её похода); 0 -- цель -- узел карты
|
||||
public int meetX, meetY; // где отряд перехватит армию (милли-единицы карты)
|
||||
public int leaveX, leaveY; // где наблюдение кончится -- отсюда домой
|
||||
public long gatherEndMs; // когда сбор закончится (груз полон, точка истощена или исчезла)
|
||||
public int cargo; // сколько ресурса несёт войско (известно с начала сбора)
|
||||
public int defenders; // гарнизон цели в момент боя
|
||||
@@ -200,6 +203,7 @@ namespace Bogatyri.World
|
||||
{
|
||||
if (m == null || m.state != MarchState.Outbound || !marches.Contains(m)) return false;
|
||||
m.units = units;
|
||||
if (IsArmyRecon(m)) { PlanArmyRecon(m, units, out _); return true; } // перехват -- по скорости состава
|
||||
m.arriveMs = m.departMs + TravelTimeMs(GetNode(m.fromNodeId), GetNode(m.targetNodeId), units);
|
||||
return true;
|
||||
}
|
||||
@@ -373,7 +377,7 @@ namespace Bogatyri.World
|
||||
// Разведка: дошла -- повернула домой (Разведка видит всё вокруг войска по пути и у цели), вернулась -- дома
|
||||
if (nextKind == 0)
|
||||
{
|
||||
ArriveScout(next);
|
||||
if (IsArmyRecon(next)) ArriveArmyRecon(next); else ArriveScout(next);
|
||||
events.Add(new WorldEvent { Type = WorldEventType.ScoutArrived, March = next, TimeMs = nextMs });
|
||||
}
|
||||
else if (nextKind == 1)
|
||||
@@ -544,6 +548,7 @@ namespace Bogatyri.World
|
||||
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
||||
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 target = GetNode(m.targetNodeId);
|
||||
|
||||
|
||||
@@ -143,10 +143,14 @@ public class IntelligenceOverlay : MonoBehaviour
|
||||
}
|
||||
|
||||
// Имя владельца -- по его поселению на карте («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 (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
|
||||
if (world != null)
|
||||
foreach (var n in world.Nodes)
|
||||
if (n.ownerId == ownerId && n.type == MapNodeType.NeighborCity) return n.displayName;
|
||||
return ownerId ?? "?";
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ public class MarchView : MonoBehaviour
|
||||
void CreateRouteLine(WorldSimulation world, MarchData march)
|
||||
{
|
||||
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;
|
||||
|
||||
// Тонкая полоса на земле от города до цели
|
||||
@@ -126,7 +128,7 @@ public class MarchView : MonoBehaviour
|
||||
long secondsLeft = System.Math.Max(0, (endMs - nowMs + 999) / 1000);
|
||||
string direction = returning ? (march.cargo > 0 ? $"домой, груз {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»
|
||||
string time = secondsLeft >= 3600
|
||||
? $"{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} мин";
|
||||
}
|
||||
|
||||
// Окно чужой армии (Разведка М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()
|
||||
{
|
||||
|
||||
@@ -510,6 +510,9 @@ public class WorldMapController : MonoBehaviour
|
||||
if (ArmiesWindow.IsOpen) 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);
|
||||
if (node == null) return;
|
||||
|
||||
@@ -521,6 +524,22 @@ public class WorldMapController : MonoBehaviour
|
||||
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)
|
||||
{
|
||||
foreach (var node in World.Nodes)
|
||||
@@ -841,7 +860,7 @@ public class WorldMapController : MonoBehaviour
|
||||
|
||||
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Разведчики возвращаются домой");
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
| Поселение (соседний город) | 2 игровых часа | 4 |
|
||||
| Замок | 3 | 6 |
|
||||
| Лагерь (в ТЗ не назван — как поселение, ТЕСТ) | 2 | 4 |
|
||||
| Вражеская армия (1 / 2 ч) | **ещё нет** — движущаяся цель, позже | |
|
||||
| Вражеская армия | 1 | 2 |
|
||||
|
||||
1 игровой час = 5 реальных минут.
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
- События: `ReconStartedEvent`, `ReconCompletedEvent`. Отчёты сохраняются (`report=`), хранятся последние 30.
|
||||
- Числа — в `IntelligenceRules` (`ReconSettlementGameHours`, `ReconCastleGameHours`, `ReconCampGameHours`, `ResourceEstimatePercent`).
|
||||
|
||||
**Разведка армии (06.11):** тап по видимой чужой армии (красный ромб) — окно «ЧУЖАЯ АРМИЯ»: примерная численность, последний отчёт и «Разведка II / III» для 10 свободных воинов с планом «догонят через …, отчёт через …, дома через …». Отряд физически идёт к точке перехвата (где армия будет, когда он дойдёт), наблюдает, идя рядом с ней, отчёт — на конец наблюдения, домой — из места, где наблюдение кончилось. Не догнать или армия уйдёт с карты раньше конца наблюдения — поход не начинается, причина в окне. Армия пропала до перехвата — отряд дошёл до точки и вернулся без отчёта. Цель отчёта — `march_<номер>` (как в `GetArmies`), снимок — `MarchManager.TryGetReconTarget` (`ReconTargetKind.Army`: точный состав, без стен и ресурсов). Время — `IntelligenceRules.ReconArmyGameHours` {1, 2}. Патрули-заглушки идут ~40 минут — их можно догнать, только если они проходят рядом с городом.
|
||||
|
||||
**На карте:** в окне атаки — «Разведка 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.5D: туман рисуется в текстуре земли (объекты стоят над ней и видны всегда), метки чужих армий — ромбы-кубы с подписью к камере. `hideUnexploredObjects` в 3D пока только сильнее затемняет землю. |
|
||||
| 01.11.2026 | Claude | Поход «Разведка» (решения 31.10, п. 2) — команда Мира; что войско увидит, показывает Разведка. См. World.md. |
|
||||
|
||||
@@ -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 | `MarchData.missingUnits`: при бое пропавшие передаются Армии (`MarkMissing`); `MarchReturnedEvent.Missing`. |
|
||||
| 04.11.2026 | Claude | Карта мира в 2.5D на примитивах: карта лежит на земле (X/Z), замок — стена с башней, города — кубы, лагеря — цилиндры, точки — ромбы, отряды — кубики с полосой маршрута; подписи смотрят в камеру; наклонная перспективная камера (`MapCameraController`: перетаскивание, щипок, тап, границы); солнце в сцене; камера — 3D-рендерер URP (`Map3D.UseRenderer3D`). Логика, окна и кнопки не менялись. Ветка `feature/3d-settlement`. |
|
||||
|
||||
Reference in New Issue
Block a user