/
Alexwww228
/
BridgeBattleSimulator
Обзор
Документация
Войти
/
Alexwww228
/
BridgeBattleSimulator
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
BattleSimulator.UI/ViewModels/BattleViewModel.cs
906 строк
36 KB
Alexwww228
fix: Save system and Abstract Factory
24 май 2026, 01:45
24 май 2026, 01:45
7391992
Код
Авторство
О чём код?
using System.Collections.ObjectModel; using System.Diagnostics; using System.Reactive.Linq; using System.Timers; using ReactiveUI; using BattleSimulator.Application.Services; using BattleSimulator.Application.Services.Events; using BattleSimulator.Application.Services.Observers; using BattleSimulator.Application.Config; using BattleSimulator.Application.Strategies; using BattleSimulator.Domain.Interfaces; using BattleSimulator.Domain.Units; using BattleSimulator.UI.Services; using BattleSimulator.UI.Services.Commands; using Avalonia.Threading; namespace BattleSimulator.UI.ViewModels; /// <summary> /// ViewModel экрана боя. /// /// Паттерн MVVM: ViewModel подписывается на события Battlefield (Observer), /// обновляет реактивные свойства и публикует их в View через биндинги. /// /// Паттерн Наблюдатель: _dispatcher.Subscribe(OnEvent) — Battlefield /// публикует события, BattleViewModel обрабатывает их и обновляет UI. /// /// Все обновления UI диспетчеризуются в UI-поток через Dispatcher.UIThread.Post. /// </summary> public sealed class BattleViewModel : ReactiveObject, IDisposable { private readonly Battlefield _battlefield; private readonly IEventDispatcher _dispatcher; private readonly Army _army1; private readonly Army _army2; private readonly GameSaveService _saveService; private readonly int _saveSlot; private readonly DamageObservable _damageObservable; private readonly CommandManager _commandManager = new(); public Battlefield Battlefield => _battlefield; public Army Army1 => _army1; public Army Army2 => _army2; /// <summary>Вызывается после каждого выполненного хода с текущими армиями (для автосохранения).</summary> public event Action<Army, Army>? TurnCompleted; /// <summary>Вызывается, когда пользователь запрашивает выход из боя в главное меню.</summary> public event Action? ExitToMenuRequested; /// <summary> /// Вызывается, когда пользователь нажимает «Сохранить» в бою. /// MainViewModel перехватывает это событие и открывает экран выбора слота в режиме сохранения. /// </summary> public event Action? SaveSlotRequested; // Таймер для авто-режима (один ход каждые N миллисекунд) private readonly System.Timers.Timer _autoTimer; private bool _executingTurn; private bool _isRestoring; // Таймер для автосохранения каждые 30 секунд private readonly System.Timers.Timer _autoSaveTimer; /// <summary> /// Создать VM боя для двух армий. /// Подписывается на события и строит ObservableCollection юнитов. /// </summary> public BattleViewModel(Army army1, Army army2, int saveSlot = 1) { _saveSlot = saveSlot; _dispatcher = new EventDispatcher(); _damageObservable = new DamageObservable(); _battlefield = new Battlefield(army1, army2, _dispatcher, _damageObservable); _army1 = army1; _army2 = army2; _saveService = new GameSaveService(); foreach (var observer in DamageObserverFactory.CreateObservers(GameSettings.Instance)) _damageObservable.Subscribe(observer); Debug.WriteLine($"✅ Observers subscribed. Total observers: {_damageObservable.ObserverCount}"); Army1Units = BuildUnitCollection(_army1); Army2Units = BuildUnitCollection(_army2); Army1Name = _army1.ArmyName; Army2Name = _army2.ArmyName; // Подписываемся на события боя (паттерн Наблюдатель) _dispatcher.Subscribe(OnEvent); // Команды управления боем (паттерн Command из MVVM) NextTurnCommand = ReactiveCommand.Create( ExecuteNextTurnCommand, this.WhenAnyValue(x => x.IsOver, x => x.IsAutoPlaying, (over, auto) => !over && !auto) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); RunToEndCommand = ReactiveCommand.Create( ExecuteRunToEndCommand, this.WhenAnyValue(x => x.IsOver, x => x.IsAutoPlaying, (over, auto) => !over && !auto) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); AutoPlayCommand = ReactiveCommand.Create( () => ExecuteNonUndoable("Авто-бой", StartAutoPlay), this.WhenAnyValue(x => x.IsOver, x => x.IsAutoPlaying, (over, auto) => !over && !auto) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); StopAutoCommand = ReactiveCommand.Create( () => ExecuteNonUndoable("Остановить авто-бой", StopAutoPlay), this.WhenAnyValue(x => x.IsAutoPlaying) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); // Save/Load commands SaveGameCommand = ReactiveCommand.Create( () => ExecuteNonUndoable("Сохранить игру", SaveGame), this.WhenAnyValue(x => x.IsOver, over => !over) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); // Formation change commands ChangeFormationCommand = ReactiveCommand.Create<object?>( ChangeFormationFromParameter, this.WhenAnyValue(x => x.IsOver, over => !over) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); // Exit to menu command ExitToMenuCommand = ReactiveCommand.Create( () => ExecuteNonUndoable("Выход в меню", ExitToMenu), Observable.Return(true) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); UndoCommand = ReactiveCommand.Create( Undo, this.WhenAnyValue(x => x.CanUndo, x => x.IsAutoPlaying, (canUndo, auto) => canUndo && !auto) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); RedoCommand = ReactiveCommand.Create( Redo, this.WhenAnyValue(x => x.CanRedo, x => x.IsAutoPlaying, (canRedo, auto) => canRedo && !auto) .ObserveOn(RxApp.MainThreadScheduler), RxApp.MainThreadScheduler); _commandManager.HistoryChanged += () => { CanUndo = _commandManager.CanUndo; CanRedo = _commandManager.CanRedo; }; // Таймер авто-режима: интервал 250 мс, ход не реентерабелен (_executingTurn) _autoTimer = new System.Timers.Timer(250); _autoTimer.Elapsed += (_, _) => { Dispatcher.UIThread.Post(() => { if (_battlefield.IsOver || _executingTurn) return; ExecuteNextTurnCommand(); }); }; // Таймер автосохранения каждые 30 секунд _autoSaveTimer = new System.Timers.Timer(30000); _autoSaveTimer.Elapsed += (_, _) => { Dispatcher.UIThread.Post(() => { // Автосохранение по таймеру делегируется MainViewModel через событие TurnCompleted. // Здесь ничего не делаем, чтобы не дублировать логику слота. }); }; _autoSaveTimer.Start(); Log("Бой начался! ⚔", LogType.Инфо); } // ── Bindable-свойства ──────────────────────────────────────────────── public string Army1Name { get; } public string Army2Name { get; } /// <summary>Список ViewModel юнитов армии 1 (для левой панели).</summary> public ObservableCollection<UnitViewModel> Army1Units { get; } /// <summary>Список ViewModel юнитов армии 2 (для правой панели).</summary> public ObservableCollection<UnitViewModel> Army2Units { get; } /// <summary>Колонны армии 1 для режима 3×3.</summary> public ObservableCollection<UnitColumnViewModel> Army1Columns { get; } = new(); /// <summary>Колонны армии 2 для режима 3×3.</summary> public ObservableCollection<UnitColumnViewModel> Army2Columns { get; } = new(); /// <summary>Журнал боя — новые записи добавляются в начало.</summary> public ObservableCollection<BattleLogEntry> BattleLog { get; } = new(); /// <summary>Завершён ли бой.</summary> private bool _isOver; public bool IsOver { get => _isOver; private set => this.RaiseAndSetIfChanged(ref _isOver, value); } /// <summary>Активен ли авто-режим.</summary> private bool _isAutoPlaying; public bool IsAutoPlaying { get => _isAutoPlaying; private set => this.RaiseAndSetIfChanged(ref _isAutoPlaying, value); } /// <summary>Текст победителя (null во время боя).</summary> private string? _winnerText; public string? WinnerText { get => _winnerText; private set => this.RaiseAndSetIfChanged(ref _winnerText, value); } /// <summary>Номер текущего хода.</summary> private int _turnNumber; public int TurnNumber { get => _turnNumber; private set => this.RaiseAndSetIfChanged(ref _turnNumber, value); } private bool _canUndo; public bool CanUndo { get => _canUndo; private set => this.RaiseAndSetIfChanged(ref _canUndo, value); } private bool _canRedo; public bool CanRedo { get => _canRedo; private set => this.RaiseAndSetIfChanged(ref _canRedo, value); } private int _currentFormationIndex; public int CurrentFormationIndex { get => _currentFormationIndex; private set => this.RaiseAndSetIfChanged(ref _currentFormationIndex, value); } public double UnitCardWidth => _currentFormationIndex == 1 ? 200 : 210; /// <summary>Описание бойца армии 1 у моста.</summary> private string _fighter1Text = "-"; public string Fighter1Text { get => _fighter1Text; private set => this.RaiseAndSetIfChanged(ref _fighter1Text, value); } /// <summary>Описание бойца армии 2 у моста.</summary> private string _fighter2Text = "-"; public string Fighter2Text { get => _fighter2Text; private set => this.RaiseAndSetIfChanged(ref _fighter2Text, value); } // ── Команды ───────────────────────────────────────────────────────── /// <summary>Следующий ход (ручной режим).</summary> public ReactiveCommand<RxUnit, RxUnit> NextTurnCommand { get; } public ReactiveCommand<RxUnit, RxUnit> RunToEndCommand { get; } /// <summary>Запустить авто-режим.</summary> public ReactiveCommand<RxUnit, RxUnit> AutoPlayCommand { get; } /// <summary>Остановить авто-режим.</summary> public ReactiveCommand<RxUnit, RxUnit> StopAutoCommand { get; } /// <summary>Сохранить игру.</summary> public ReactiveCommand<RxUnit, RxUnit> SaveGameCommand { get; } /// <summary>Изменить боевую тактику (0=Classic, 1=Thick, 2=WallToWall).</summary> public ReactiveCommand<object?, RxUnit> ChangeFormationCommand { get; } /// <summary>Выход в главное меню.</summary> public ReactiveCommand<RxUnit, RxUnit> ExitToMenuCommand { get; } public ReactiveCommand<RxUnit, RxUnit> UndoCommand { get; } public ReactiveCommand<RxUnit, RxUnit> RedoCommand { get; } // ── Действия ──────────────────────────────────────────────────────── private void ExecuteNextTurnCommand() { var before = CaptureCommandSnapshot(); NextTurnCore(); // Захватываем «после» через InvokeAsync — ждём пока все события (UnitDied и др.) // попадут в журнал через Dispatcher.UIThread.Post, и только потом регистрируем снапшот. Dispatcher.UIThread.InvokeAsync(() => { var after = CaptureCommandSnapshot(); _commandManager.RegisterSnapshot("Следующий ход", before, after, RestoreCommandSnapshot); }); } private void ExecuteRunToEndCommand() { var before = CaptureCommandSnapshot(); RunToEndCore(); Dispatcher.UIThread.InvokeAsync(() => { var after = CaptureCommandSnapshot(); _commandManager.RegisterSnapshot("Ход до конца игры", before, after, RestoreCommandSnapshot); }); } private void NextTurnCore() { if (_battlefield.IsOver) { StopAutoPlay(); return; } _executingTurn = true; try { _battlefield.ExecuteTurn(); TurnNumber = _battlefield.GetTurn(); // Полная пересборка карточек — иначе клоны и смена состава не попадают в UI (события часто в очереди UI). RebuildUnitCollections(); TurnCompleted?.Invoke(_army1, _army2); if (_battlefield.IsOver) { StopAutoPlay(); IsOver = true; WinnerText = _battlefield.IsDraw ? "🤝 Ничья — 50 ходов подряд без изменений (стены при долгом бою разрушаются сами)." : $"🏆 {_battlefield.WinnerName} победила!"; } } finally { _executingTurn = false; } } private void RunToEndCore() { while (!_battlefield.IsOver) NextTurnCore(); } private void StartAutoPlay() { IsAutoPlaying = true; _autoTimer.Start(); } private void StopAutoPlay() { IsAutoPlaying = false; _autoTimer.Stop(); } private void SaveGame() { // Открыть экран выбора слота в режиме сохранения (через MainViewModel) SaveSlotRequested?.Invoke(); } /// <summary>Сохранить бой в конкретный слот (вызывается из MainViewModel после выбора слота).</summary> public void SaveToSlot(int slot) { if (_saveService.SaveBattle(slot, _battlefield, _army1, _army2, TurnNumber)) { Log($"💾 Игра сохранена в слот {slot}! Ход {TurnNumber}", LogType.Инфо); } else { Log($"❌ Ошибка при сохранении в слот {slot}.", LogType.Ошибка); } } public void LoadFromSave() { var saveData = _saveService.LoadBattle(_saveSlot); if (saveData == null) { Log($"❌ Сохранение не найдено (слот {_saveSlot}).", LogType.Ошибка); return; } try { // Очищаем журнал перед загрузкой, чтобы не смешивать записи разных сессий BattleLog.Clear(); // Восстановить номер хода Battlefield.SetTurn(saveData.TurnNumber); TurnNumber = saveData.TurnNumber; // Восстановить стратегию боевого порядка ДО восстановления юнитов if (!string.IsNullOrEmpty(saveData.FormationStrategy)) { int strategyIndex = saveData.FormationStrategy switch { "ThickBridgeStrategy" => 1, "WallToWallStrategy" => 2, _ => 0 }; ChangeFormationCore(strategyIndex); } // Пересобрать коллекции карточек в UI RebuildUnitCollections(); UpdateFighterDisplay(); // Проверить, был ли бой уже окончен в момент сохранения if (saveData.IsOver) { IsOver = true; WinnerText = saveData.IsDraw ? "Ничья" : saveData.Winner != null ? $"Победитель: {saveData.Winner}" : "Ничья"; } _commandManager.ClearHistory(); Log($"✅ Игра загружена! Ход {TurnNumber}", LogType.Инфо); Log($" Армия 1: {_army1.AliveCount} живых из {_army1.Units.Count}", LogType.Инфо); Log($" Армия 2: {_army2.AliveCount} живых из {_army2.Units.Count}", LogType.Инфо); } catch (Exception ex) { Log($"❌ Ошибка при загрузке: {ex.Message}", LogType.Ошибка); System.Diagnostics.Debug.WriteLine($"LoadFromSave exception: {ex}"); } } private void ChangeFormationFromParameter(object? param) { if (param == null) return; if (int.TryParse(param.ToString(), out var strategyIndex)) { _commandManager.Execute(new SnapshotGameCommand<BattleCommandSnapshot>( "Перестроение армий", () => ChangeFormationCore(strategyIndex), CaptureCommandSnapshot, RestoreCommandSnapshot)); } } private void ChangeFormationCore(int strategyIndex) { var formationContext = _battlefield.FormationContext; if (formationContext == null) return; IBattleFormationStrategy newStrategy = strategyIndex switch { 0 => new ClassicBridgeStrategy(), 1 => new ThickBridgeStrategy(), 2 => new WallToWallStrategy(), _ => formationContext.CurrentStrategy }; formationContext.SwitchStrategy(newStrategy, _army1, _army2); RebuildUnitCollections(); // Update fighter display for new formation UpdateFighterDisplay(); string formationName = strategyIndex switch { 0 => "Классический мост", 1 => "Толстый мост (3 в ряд)", 2 => "Стена в стену", _ => "Неизвестная" }; CurrentFormationIndex = strategyIndex; this.RaisePropertyChanged(nameof(UnitCardWidth)); Log($"⚔ Боевая тактика изменена на: {formationName}", LogType.Инфо); } private void ExitToMenu() { StopAutoPlay(); _autoSaveTimer.Stop(); Log("Выход в главное меню...", LogType.Инфо); ExitToMenuRequested?.Invoke(); } private void Undo() => _commandManager.Undo(); private void Redo() => _commandManager.Redo(); private static void ExecuteNonUndoable(string name, Action action) => new ActionGameCommand(name, action).Execute(); private BattleCommandSnapshot CaptureCommandSnapshot() => new( _saveService.CreateSnapshot(_battlefield, _army1, _army2, _battlefield.GetTurn()), BattleLog.ToList(), _battlefield.GetTurn(), Fighter1Text, Fighter2Text, IsOver, WinnerText); private void RestoreCommandSnapshot(BattleCommandSnapshot snapshot) { _isRestoring = true; try { StopAutoPlay(); _saveService.RestoreSnapshot(snapshot.SaveData, _army1, _army2); RestoreFormation(snapshot.SaveData.FormationStrategy); _battlefield.RestoreState( snapshot.SaveData.TurnNumber, snapshot.SaveData.IsOver, snapshot.SaveData.IsDraw, snapshot.SaveData.Winner); TurnNumber = snapshot.TurnNumber; IsOver = snapshot.IsOver; WinnerText = snapshot.WinnerText; Fighter1Text = snapshot.Fighter1Text; Fighter2Text = snapshot.Fighter2Text; BattleLog.Clear(); foreach (var entry in snapshot.BattleLog) BattleLog.Add(entry); RebuildUnitCollections(); RefreshAllUnits(); } finally { _isRestoring = false; } } private void RestoreFormation(string strategyName) { IBattleFormationStrategy strategy = strategyName switch { "ThickBridgeStrategy" => new ThickBridgeStrategy(), "WallToWallStrategy" => new WallToWallStrategy(), _ => new ClassicBridgeStrategy() }; _battlefield.FormationContext.SwitchStrategy(strategy, _army1, _army2); } // ── Обработка событий (паттерн Наблюдатель) ───────────────────────── /// <summary> /// Получить событие от Battlefield. /// События могут приходить из фонового потока (авто-режим) — /// перенаправляем обработку в UI-поток. /// </summary> private void OnEvent(IBattleEvent evt) { if (_isRestoring) return; Dispatcher.UIThread.Post(() => HandleEvent(evt)); } private void HandleEvent(IBattleEvent evt) { switch (evt) { case TurnStarted ts: TurnNumber = ts.TurnNumber; // Get formation context to determine how many fighters to show var formation = _battlefield.FormationContext.CurrentStrategy; string strategyName = formation.GetType().Name; // Build display text based on formation type if (strategyName.Contains("ThickBridge")) { // Берём фронтовых из VM-коллекций — тот же порядок что в блоке "В бою" var front1 = Army1Units.Where(u => u.IsFront).ToList(); var front2 = Army2Units.Where(u => u.IsFront).ToList(); Fighter1Text = front1.Count > 0 ? string.Join(" | ", front1.Select(u => u.Name)) : "-"; Fighter2Text = front2.Count > 0 ? string.Join(" | ", front2.Select(u => u.Name)) : "-"; } else if (strategyName.Contains("WallToWall")) { // Show all fighters var army1Alive = formation.GetAliveUnitsWithIndex(_army1).ToList(); var army2Alive = formation.GetAliveUnitsWithIndex(_army2).ToList(); Fighter1Text = string.Join(" | ", army1Alive.Take(5).Select(u => $"{u.Unit.Name}")); if (army1Alive.Count > 5) Fighter1Text += $" +{army1Alive.Count - 5}"; Fighter2Text = string.Join(" | ", army2Alive.Take(5).Select(u => $"{u.Unit.Name}")); if (army2Alive.Count > 5) Fighter2Text += $" +{army2Alive.Count - 5}"; } else { // Classic: show just the front unit Fighter1Text = $"{ts.Attacker1.UnitType} «{ts.Attacker1.Name}»"; Fighter2Text = $"{ts.Attacker2.UnitType} «{ts.Attacker2.Name}»"; } Log($"══ Ход {ts.TurnNumber} ══ {ts.Attacker1.Name} против {ts.Attacker2.Name}", LogType.Ход); break; case DamageDealt dd: Log($" ⚔ «{dd.Attacker.Name}» → {dd.Damage} урона → «{dd.Defender.Name}» (ХП {dd.Defender.Hp}/{dd.Defender.MaxHp})", LogType.Урон); RefreshUnit(dd.Attacker); RefreshUnit(dd.Defender); break; case UnitDied ud: Log($" ☠ «{ud.Unit.Name}» [{ud.ArmyName}] погиб!", LogType.Смерть); RefreshUnit(ud.Unit); UpdateFrontFlags(); UpdateFighterDisplay(); break; case SpecialAbilityUsed sa: Log($" {sa.Message}", LogType.Способность); // Перестроить списки юнитов в UI на случай появления клона/нового юнита RebuildUnitCollections(); UpdateFighterDisplay(); break; case BattleEnded be: if (be.IsDraw) Log("🤝 БОЙ ОКОНЧЕН — ничья (50 ходов без изменений; стены при затяжном бою получают износ).", LogType.Ход); else Log($"🏆 БОЙ ОКОНЧЕН — победила армия «{be.WinnerArmyName}»!", LogType.Победа); break; } } // ── Вспомогательные методы ────────────────────────────────────────── /// <summary>Добавить запись в журнал (в начало — новые сверху).</summary> private void Log(string msg, LogType type) => BattleLog.Insert(0, new BattleLogEntry(msg, type)); /// <summary>Найти VM юнита по доменному объекту.</summary> private UnitViewModel? FindVm(IUnit unit) => Army1Units.FirstOrDefault(v => v.Name == unit.Name && v.UnitType == unit.UnitType) ?? Army2Units.FirstOrDefault(v => v.Name == unit.Name && v.UnitType == unit.UnitType); /// <summary>Обновить VM конкретного юнита.</summary> private void RefreshUnit(IUnit unit) => FindVm(unit)?.Refresh(); /// <summary>Обновить все VM юнитов (вызывается после хода).</summary> private void RefreshAllUnits() { foreach (var vm in Army1Units) vm.Refresh(); foreach (var vm in Army2Units) vm.Refresh(); UpdateFrontFlags(); UpdateFighterDisplay(); } /// <summary>Перестроить коллекции ViewModel юнитов на основе текущих армий.</summary> private void RebuildUnitCollections() { Army1Units.Clear(); foreach (var vm in BuildUnitCollection(_army1)) Army1Units.Add(vm); Army2Units.Clear(); foreach (var vm in BuildUnitCollection(_army2)) Army2Units.Add(vm); UpdateFrontFlags(); RebuildColumns(); } private void RebuildColumns() { RebuildArmyColumns(Army1Columns, Army1Units); RebuildArmyColumns(Army2Columns, Army2Units); } private static void RebuildArmyColumns( ObservableCollection<UnitColumnViewModel> columns, ObservableCollection<UnitViewModel> units) { columns.Clear(); var list = units.ToList(); // Первый блок — фронтовые юниты в порядке из Army1/2Units (совпадает с мостом) var front = list.Where(u => u.IsFront).ToList(); var bench = list.Where(u => !u.IsFront && u.IsAlive).ToList(); var dead = list.Where(u => !u.IsAlive).ToList(); if (front.Count > 0) columns.Add(new UnitColumnViewModel(0, front)); int colIndex = 1; for (int i = 0; i < bench.Count; i += 3) { var chunk = bench.Skip(i).Take(3).ToList(); columns.Add(new UnitColumnViewModel(colIndex, chunk)); colIndex++; } if (dead.Count > 0) columns.Add(new UnitColumnViewModel(colIndex, dead)); } /// <summary>Живые в начале списка, погибшие в конце — порядок внутри групп как в армии.</summary> private static ObservableCollection<UnitViewModel> BuildUnitCollection(Army army) { var ordered = OrderUnitsForDisplay(army.Units); var col = new ObservableCollection<UnitViewModel>(); foreach (var u in ordered) col.Add(new UnitViewModel(u)); return col; } private static IEnumerable<IUnit> OrderUnitsForDisplay(IReadOnlyList<IUnit> units) { var indexed = units.Select((u, i) => (u, i)).ToList(); foreach (var x in indexed.Where(t => t.u.IsAlive).OrderBy(t => t.i)) yield return x.u; foreach (var x in indexed.Where(t => !t.u.IsAlive).OrderBy(t => t.i)) yield return x.u; } private void UpdateFrontFlags() { var formation = _battlefield.FormationContext.CurrentStrategy; // Получаем индексы (позиции в армии) юнитов на линии фронта var front1Indices = GetFrontLineIndices(formation, _army1); var front2Indices = GetFrontLineIndices(formation, _army2); // Сопоставляем VM с юнитами через индекс в списке армии. // Идентификация по имени+типу ломается когда маг создаёт клона — клон имеет то же имя и тип, // и тогда несколько VM получают IsFront = true, что нарушает первую колонну в режиме 3×3. MapFrontFlagsByIndex(Army1Units, _army1, front1Indices); MapFrontFlagsByIndex(Army2Units, _army2, front2Indices); } /// <summary>Получить множество индексов юнитов на линии фронта в армии.</summary> private static HashSet<int> GetFrontLineIndices(IBattleFormationStrategy formation, Army army) { var frontLine = formation.GetFrontLine(army); var result = new HashSet<int>(); var units = army.Units; foreach (var frontUnit in frontLine) { for (int i = 0; i < units.Count; i++) { if (ReferenceEquals(units[i], frontUnit)) { result.Add(i); break; } } } return result; } private static void MapFrontFlagsByIndex( ObservableCollection<UnitViewModel> vmList, Army army, HashSet<int> frontIndices) { var armyUnits = army.Units; // Перебираем VM последовательно, сопоставляя каждой первый ещё не занятый юнит // с совпадающим именем и типом. Это корректно обрабатывает клонов (одинаковое имя/тип). var usedArmyIndices = new HashSet<int>(); foreach (var vm in vmList) { int armyIndex = -1; for (int i = 0; i < armyUnits.Count; i++) { if (usedArmyIndices.Contains(i)) continue; var u = armyUnits[i]; if (u.Name == vm.Name && u.UnitType == vm.UnitType) { armyIndex = i; usedArmyIndices.Add(i); break; } } vm.IsFront = armyIndex >= 0 && vm.IsAlive && frontIndices.Contains(armyIndex); } } /// <summary>Обновить отображение бойцов на мосту в зависимости от тактики.</summary> private void UpdateFighterDisplay() { var formation = _battlefield.FormationContext.CurrentStrategy; string strategyName = formation.GetType().Name; if (strategyName.Contains("ThickBridge")) { // Берём фронтовых из VM-коллекций — тот же порядок что в блоке "В бою" var front1 = Army1Units.Where(u => u.IsFront).ToList(); var front2 = Army2Units.Where(u => u.IsFront).ToList(); Fighter1Text = front1.Count > 0 ? string.Join(" | ", front1.Select(u => u.Name)) : "-"; Fighter2Text = front2.Count > 0 ? string.Join(" | ", front2.Select(u => u.Name)) : "-"; } else if (strategyName.Contains("WallToWall")) { // Показать всех живых (до 5 полностью, остальных числом) var army1Alive = formation.GetAliveUnitsWithIndex(_army1).ToList(); var army2Alive = formation.GetAliveUnitsWithIndex(_army2).ToList(); if (army1Alive.Count > 0) { Fighter1Text = string.Join(" | ", army1Alive.Take(5).Select(u => u.Unit.Name)); if (army1Alive.Count > 5) Fighter1Text += $" +{army1Alive.Count - 5}"; } else Fighter1Text = "-"; if (army2Alive.Count > 0) { Fighter2Text = string.Join(" | ", army2Alive.Take(5).Select(u => u.Unit.Name)); if (army2Alive.Count > 5) Fighter2Text += $" +{army2Alive.Count - 5}"; } else Fighter2Text = "-"; } else { // Classic: show just first unit var army1Alive = formation.GetAliveUnitsWithIndex(_army1).FirstOrDefault(); var army2Alive = formation.GetAliveUnitsWithIndex(_army2).FirstOrDefault(); Fighter1Text = army1Alive.Unit != null ? $"{army1Alive.Unit.UnitType} «{army1Alive.Unit.Name}»" : "-"; Fighter2Text = army2Alive.Unit != null ? $"{army2Alive.Unit.UnitType} «{army2Alive.Unit.Name}»" : "-"; } } /// <inheritdoc/> public void Dispose() { _autoTimer.Dispose(); _autoSaveTimer.Dispose(); NextTurnCommand.Dispose(); RunToEndCommand.Dispose(); AutoPlayCommand.Dispose(); StopAutoCommand.Dispose(); SaveGameCommand.Dispose(); UndoCommand.Dispose(); RedoCommand.Dispose(); ExitToMenuCommand.Dispose(); } private sealed record BattleCommandSnapshot( BattleSaveData SaveData, IReadOnlyList<BattleLogEntry> BattleLog, int TurnNumber, string Fighter1Text, string Fighter2Text, bool IsOver, string? WinnerText); }