Files
MVP1/Assets/Scripts/Modules/Army/ArmyService.cs
T
AlexanderandClaude Opus 5.5 460d1db2de Боевая система с типами войск
Модуль Боя (Bogatyri.Battle, без Unity и float):
- сила стороны по типам войск, сила воина — из GameData.Troops (4/5/9/16)
- контры +25%: пехота > кавалерия > лучники > пехота (по составу врага)
- стены +20% защитникам; осадные +50% против стен, −25% в поле
- потери по типам: проигравший — половина, победитель — до половины (чем ровнее бой, тем больше)
- BattleRequest/BattleOutcome расширены (состав, стены, бонусы, сила сторон); бой «числом» считается пехотой
- формула — предложение, числа константами в BattleSystem.cs, ждёт утверждения командой

Мир и Армия:
- состав гарнизона у каждой цели (WorldMapLayout.GarrisonUnits, из id — карта не изменилась)
- поход хранит состав по типам; выжившие возвращаются точно по типам (IArmy.GetMarchUnits/ReturnUnits)
- время походов по GameClock: перемотка в DEV-панели проматывает и походы

Интерфейс карты:
- окно атаки: состав гарнизона и ожидаемый исход (сила сторон, победа/поражение, потери)
- верхняя панель: воины в походах (а не число отрядов)

Тесты: контры, стены, осадные, потери в пределах состава, состав гарнизонов.
Документы Battle, World, Army, Core, README обновлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:34:58 +01:00

219 lines
7.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
// МОДУЛЬ АРМИИ: единственное место, где хранятся войска. Документ: Docs/Modules/Army.md
// Без Unity: правила те же, что будут на сервере.
//
// Один солдат -- в одном месте: либо в городе (troops), либо в походе (marches).
// Поход забирает воинов из города и запоминает состав, возвращение раздаёт выживших по тем же типам.
public sealed class ArmyService : IArmy, ISaveParticipant
{
public const int Types = 4; // пехота, лучники, кавалерия, осадные -- порядок как в GameData.Troops
private class MarchUnits
{
public int marchId;
public int[] units = new int[Types];
}
private readonly int[] troops = new int[Types];
private readonly List<MarchUnits> marches = new List<MarchUnits>();
// Секция "army" была в сохранении -- старые войска из progress.json переносить не нужно
public bool RestoredFromSave { get; private set; }
// ---------------- Контракт IArmy ----------------
public int TypeCount => Types;
public int GetTroops(int type)
{
return type >= 0 && type < Types ? troops[type] : 0;
}
public int SoldiersAtHome
{
get { int n = 0; foreach (var t in troops) n += t; return n; }
}
public int TroopsInMarches
{
get
{
int n = 0;
foreach (var m in marches) foreach (var u in m.units) n += u;
return n;
}
}
public void AddTroops(int type, int count)
{
if (type < 0 || type >= Types || count <= 0) return;
troops[type] += count;
Changed();
}
public int LoseFraction(double fraction)
{
if (fraction <= 0) return 0;
if (fraction > 1) fraction = 1;
int lost = 0;
for (int i = 0; i < Types; i++)
{
int l = (int)Math.Floor(troops[i] * fraction);
troops[i] -= l;
lost += l;
}
if (lost > 0) Changed();
return lost;
}
public bool TakeForMarch(int marchId, int count)
{
int[] taken = ArmyMath.TakeProportional(troops, count);
if (taken == null) return false;
for (int i = 0; i < Types; i++) troops[i] -= taken[i];
marches.RemoveAll(m => m.marchId == marchId);
marches.Add(new MarchUnits { marchId = marchId, units = taken });
Changed();
return true;
}
public void ReturnFromMarch(int marchId, int survivors)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
if (entry == null)
{
troops[0] += Math.Max(0, survivors); // состав неизвестен (очень старое сохранение) -- считаем пехотой
}
else
{
int[] back = ArmyMath.SplitSurvivors(entry.units, survivors);
for (int i = 0; i < Types; i++) troops[i] += back[i];
marches.Remove(entry);
}
Changed();
}
public int[] GetMarchUnits(int marchId)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
return entry != null ? (int[])entry.units.Clone() : null;
}
// Выжившие точно по типам: бой посчитал потери каждого типа. Больше, чем ушло, вернуться не может.
public void ReturnUnits(int marchId, int[] survivorUnits)
{
MarchUnits entry = marches.Find(m => m.marchId == marchId);
for (int i = 0; i < Types && survivorUnits != null && i < survivorUnits.Length; i++)
{
int back = Math.Max(0, survivorUnits[i]);
if (entry != null) back = Math.Min(back, entry.units[i]);
troops[i] += back;
}
if (entry != null) marches.Remove(entry);
Changed();
}
void Changed()
{
EventBus.Publish(new TroopsChangedEvent { AtHome = SoldiersAtHome, InMarches = TroopsInMarches });
}
// ---------------- Перенос из старого progress.json ----------------
// Войска раньше хранил GameProgress (troops) и мост ArmyBridge (sashaMarchUnits).
// Вызывается один раз, если в save.json ещё нет секции "army".
public void ImportLegacy(int[] legacyTroops, IEnumerable<KeyValuePair<int, int[]>> legacyMarches)
{
if (legacyTroops != null)
for (int i = 0; i < Types && i < legacyTroops.Length; i++) troops[i] = Math.Max(0, legacyTroops[i]);
marches.Clear();
if (legacyMarches != null)
{
foreach (var m in legacyMarches)
{
var units = new int[Types];
for (int i = 0; i < Types && m.Value != null && i < m.Value.Length; i++) units[i] = Math.Max(0, m.Value[i]);
marches.Add(new MarchUnits { marchId = m.Key, units = units });
}
}
Changed();
}
// ---------------- Сохранение (секция "army") ----------------
// Модуль без Unity, поэтому формат -- простые строки:
// troops=<пехота>,<лучники>,<кавалерия>,<осадные>
// march=<id похода>:<пехота>,<лучники>,<кавалерия>,<осадные>
public string SaveKey => "army";
public string Capture()
{
var sb = new StringBuilder();
sb.Append("troops=").Append(Join(troops)).Append('\n');
foreach (var m in marches)
sb.Append("march=").Append(m.marchId.ToString(CultureInfo.InvariantCulture)).Append(':').Append(Join(m.units)).Append('\n');
return sb.ToString();
}
public void Restore(string data)
{
Array.Clear(troops, 0, Types);
marches.Clear();
RestoredFromSave = true;
if (!string.IsNullOrEmpty(data))
{
foreach (var raw in data.Split('\n'))
{
string line = raw.Trim();
if (line.StartsWith("troops="))
{
int[] t = Parse(line.Substring("troops=".Length));
Array.Copy(t, troops, Types);
}
else if (line.StartsWith("march="))
{
string[] parts = line.Substring("march=".Length).Split(':');
if (parts.Length == 2 && int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int id))
marches.Add(new MarchUnits { marchId = id, units = Parse(parts[1]) });
}
}
}
Changed();
}
// Новая игра: войск нет, походов нет
public void ResetState()
{
Array.Clear(troops, 0, Types);
marches.Clear();
Changed();
}
static string Join(int[] values)
{
var parts = new string[values.Length];
for (int i = 0; i < values.Length; i++) parts[i] = values[i].ToString(CultureInfo.InvariantCulture);
return string.Join(",", parts);
}
static int[] Parse(string csv)
{
var result = new int[Types];
string[] parts = csv.Split(',');
for (int i = 0; i < Types && i < parts.Length; i++)
{
if (int.TryParse(parts[i].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v))
result[i] = Math.Max(0, v);
}
return result;
}
}