Модуль Боя (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>
218 lines
9.5 KiB
C#
218 lines
9.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Bogatyri.World
|
|
{
|
|
public enum MarchState
|
|
{
|
|
Outbound, // идут к цели
|
|
Returning, // бой прошёл, возвращаются домой
|
|
Finished // вернулись, солдаты и добыча зачислены
|
|
}
|
|
|
|
// Один поход. Всё время -- абсолютное (Unix-время в миллисекундах), а не "осталось N секунд":
|
|
// поход продолжается, пока игра закрыта, и одинаково понимается клиентом и сервером.
|
|
[Serializable]
|
|
public class MarchData
|
|
{
|
|
public int id;
|
|
public string ownerId;
|
|
public string fromNodeId;
|
|
public string targetNodeId;
|
|
|
|
public int soldiers; // сколько ушло
|
|
public int survivors; // сколько возвращается (известно после боя)
|
|
public bool victory;
|
|
public int defenders; // гарнизон цели в момент боя
|
|
public int defenderLosses; // сколько потерял защитник
|
|
public int[] units; // состав похода по типам (TroopType); пусто -- старый поход, все считаются пехотой
|
|
public int[] survivorUnits; // выжившие по типам (известно после боя)
|
|
public List<RewardEntry> loot = new List<RewardEntry>();
|
|
|
|
public long departMs;
|
|
public long arriveMs;
|
|
public long returnMs; // известно после боя
|
|
public MarchState state;
|
|
}
|
|
|
|
public static class MarchRules
|
|
{
|
|
// Скорость войска: 400 милли-единиц (0,4 клетки) в секунду.
|
|
// Ближний сосед (~4 клетки) -- около 10 сек, замок (~10 клеток) -- около 25 сек, край карты -- до минуты.
|
|
public const int SpeedPerSecond = 400;
|
|
|
|
public static long GetTravelTimeMs(MapNode from, MapNode to)
|
|
{
|
|
return (long)WorldMapLayout.Distance(from, to) * 1000 / SpeedPerSecond;
|
|
}
|
|
}
|
|
|
|
public enum WorldEventType
|
|
{
|
|
BattleResolved, // войско дошло, бой прошёл
|
|
MarchReturned // войско вернулось домой
|
|
}
|
|
|
|
public struct WorldEvent
|
|
{
|
|
public WorldEventType Type;
|
|
public MarchData March;
|
|
}
|
|
|
|
// Состояние мира: карта + походы. Не зависит от Unity -- в мультиплеере этот же код крутится на сервере,
|
|
// а клиент только показывает то, что он вернул.
|
|
public sealed class WorldSimulation
|
|
{
|
|
private readonly List<MapNode> nodes;
|
|
private readonly IBattleSystem battleSystem;
|
|
private readonly List<MarchData> marches = new List<MarchData>();
|
|
private int nextMarchId = 1;
|
|
|
|
public IReadOnlyList<MapNode> Nodes => nodes;
|
|
public IReadOnlyList<MarchData> Marches => marches;
|
|
|
|
// Бой считает модуль Боевой системы -- Мир получает его через контракт, а не знает формулу сам
|
|
public WorldSimulation(List<MapNode> nodes, IBattleSystem battleSystem)
|
|
{
|
|
this.nodes = nodes;
|
|
this.battleSystem = battleSystem;
|
|
}
|
|
|
|
public MapNode GetNode(string id)
|
|
{
|
|
return WorldMapLayout.Find(nodes, id);
|
|
}
|
|
|
|
// Восстановление походов из сохранения (или из ответа сервера)
|
|
public void LoadMarches(List<MarchData> saved)
|
|
{
|
|
marches.Clear();
|
|
if (saved == null) return;
|
|
|
|
foreach (var m in saved)
|
|
{
|
|
// Поход к точке, которой больше нет на карте (карта поменялась), восстановить нельзя
|
|
if (GetNode(m.fromNodeId) == null || GetNode(m.targetNodeId) == null) continue;
|
|
|
|
marches.Add(m);
|
|
if (m.id >= nextMarchId) nextMarchId = m.id + 1;
|
|
}
|
|
}
|
|
|
|
public MarchData StartMarch(string ownerId, string fromId, string targetId, int soldiers, long nowMs, out string error)
|
|
{
|
|
MapNode from = GetNode(fromId);
|
|
MapNode target = GetNode(targetId);
|
|
|
|
if (from == null || target == null) { error = "Неизвестная точка на карте"; return null; }
|
|
if (target == from || !target.canBeAttacked) { error = $"На {target.displayName} нападать нельзя"; return null; }
|
|
if (soldiers <= 0) { error = "Нужно отправить хотя бы одного солдата"; return null; }
|
|
|
|
var march = new MarchData
|
|
{
|
|
id = nextMarchId++,
|
|
ownerId = ownerId,
|
|
fromNodeId = fromId,
|
|
targetNodeId = targetId,
|
|
soldiers = soldiers,
|
|
survivors = soldiers,
|
|
departMs = nowMs,
|
|
arriveMs = nowMs + MarchRules.GetTravelTimeMs(from, target),
|
|
state = MarchState.Outbound
|
|
};
|
|
marches.Add(march);
|
|
|
|
error = null;
|
|
return march;
|
|
}
|
|
|
|
// Продвинуть мир до момента nowMs. Если игра была закрыта долго, за один вызов поход
|
|
// может и дойти, и вернуться -- события придут в правильном порядке.
|
|
public void Advance(long nowMs, List<WorldEvent> events)
|
|
{
|
|
foreach (var m in marches)
|
|
{
|
|
if (m.state == MarchState.Outbound && nowMs >= m.arriveMs)
|
|
{
|
|
ResolveBattle(m);
|
|
events.Add(new WorldEvent { Type = WorldEventType.BattleResolved, March = m });
|
|
}
|
|
|
|
if (m.state == MarchState.Returning && nowMs >= m.returnMs)
|
|
{
|
|
m.state = MarchState.Finished;
|
|
events.Add(new WorldEvent { Type = WorldEventType.MarchReturned, March = m });
|
|
}
|
|
}
|
|
|
|
marches.RemoveAll(m => m.state == MarchState.Finished);
|
|
}
|
|
|
|
void ResolveBattle(MarchData m)
|
|
{
|
|
MapNode from = GetNode(m.fromNodeId);
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
|
|
BattleOutcome outcome = battleSystem.Resolve(new BattleRequest
|
|
{
|
|
AttackerUnits = HasUnits(m) ? m.units : null,
|
|
Attackers = m.soldiers,
|
|
DefenderUnits = WorldMapLayout.GarrisonUnits(target),
|
|
Defenders = target.garrison,
|
|
DefenderFortified = target.type != MapNodeType.PlayerCity // замок и города -- за стенами
|
|
});
|
|
m.victory = outcome.Victory;
|
|
m.survivors = outcome.Survivors;
|
|
m.survivorUnits = HasUnits(m) ? outcome.AttackerSurvivorUnits : null;
|
|
m.defenders = target.garrison;
|
|
m.defenderLosses = outcome.DefenderLosses;
|
|
// Гарнизон соседа пока не меняется: соседи -- заглушки. Когда они станут реальными игроками,
|
|
// их потери будет списывать сервер из армии того игрока.
|
|
m.loot = new List<RewardEntry>();
|
|
if (outcome.Victory)
|
|
{
|
|
foreach (var r in target.rewards)
|
|
m.loot.Add(new RewardEntry { resource = r.resource, amount = r.amount });
|
|
}
|
|
|
|
// Обратный путь отсчитывается от момента боя, а не от "сейчас" -- важно для офлайна
|
|
m.returnMs = m.arriveMs + MarchRules.GetTravelTimeMs(target, from);
|
|
m.state = MarchState.Returning;
|
|
}
|
|
|
|
// Состав похода известен и сходится с числом воинов (у старых походов его нет)
|
|
static bool HasUnits(MarchData m)
|
|
{
|
|
if (m.units == null || m.units.Length != TroopType.Count) return false;
|
|
int n = 0;
|
|
foreach (var u in m.units) n += u;
|
|
return n == m.soldiers && n > 0;
|
|
}
|
|
|
|
// Где сейчас войско (для отрисовки). Возвращает долю пути 0..1 и точки начала/конца отрезка.
|
|
public void GetMarchSegment(MarchData m, long nowMs, out MapNode segmentFrom, out MapNode segmentTo, out double progress)
|
|
{
|
|
MapNode home = GetNode(m.fromNodeId);
|
|
MapNode target = GetNode(m.targetNodeId);
|
|
|
|
long start, end;
|
|
if (m.state == MarchState.Outbound)
|
|
{
|
|
segmentFrom = home;
|
|
segmentTo = target;
|
|
start = m.departMs;
|
|
end = m.arriveMs;
|
|
}
|
|
else
|
|
{
|
|
segmentFrom = target;
|
|
segmentTo = home;
|
|
start = m.arriveMs;
|
|
end = m.returnMs;
|
|
}
|
|
|
|
progress = end > start ? Math.Min(1.0, Math.Max(0.0, (nowMs - start) / (double)(end - start))) : 1.0;
|
|
}
|
|
}
|
|
}
|