/
puma
/
ppa
Обзор
Документация
Войти
/
puma
/
ppa
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
EtoUI/BattleView.cs
1 355 строк
50 KB
Margarita
heavy
29 май 2026, 11:50
29 май 2026, 11:50
54d5396
Код
Авторство
О чём код?
using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using Eto.Forms; using Eto.Drawing; public class BattleView : Eto.Forms.Panel { private readonly Battle _battle; private readonly Player _player1; private readonly Player _player2; private readonly Action _onBackToMenu; private readonly CommandInvoker _invoker; // Saved at start so dead units still appear in columns private readonly List<IUnit> _p1AllUnits; private readonly List<IUnit> _p2AllUnits; // Отслеживаем, каким юнитам уже подписаны наблюдатели private readonly HashSet<IUnit> _subscribedUnits = new(); // Фиксированный начальный порядок — для формации 3×3 private readonly List<IUnit> _p1InitialOrder; private readonly List<IUnit> _p2InitialOrder; // Динамические группы 3×3: перестраиваются только при полном опустошении линии private List<List<IUnit>> _3x3P1Groups = new(); private List<List<IUnit>> _3x3P2Groups = new(); private readonly Dictionary<IUnit, Eto.Drawing.Image?> _p1UnitImages; private readonly Dictionary<IUnit, Eto.Drawing.Image?> _p2UnitImages; private readonly Eto.Forms.Label _turnLabel; private readonly Eto.Forms.TextArea _logArea; private readonly Eto.Forms.Panel _p1ColumnHost; private readonly Eto.Forms.Panel _p2ColumnHost; private readonly Eto.Forms.Panel _battleFieldHost; private readonly Eto.Forms.Button _makeRoundBtn; private readonly Eto.Forms.Button _playToEndBtn; private readonly Eto.Forms.Button _undoBtn; private readonly Eto.Forms.Button _redoBtn; private readonly Eto.Forms.Button _saveBtn; private readonly Eto.Forms.Button _exitBtn; private readonly Eto.Forms.DropDown _formationDropDown; private bool _battleEnded; private bool _syncingDropdown; // подавляет повторный вызов OnFormationChanged при синхронизации дропдауна public BattleView(Player player1, Player player2, Action onBackToMenu) : this(player1, player2, onBackToMenu, null, 0, null) { } public BattleView(Player player1, Player player2, Action onBackToMenu, Player? loadedCurrentPlayer, int loadedRoundNumber, string? loadedGameMode) { _player1 = player1 ?? throw new ArgumentNullException(nameof(player1)); _player2 = player2 ?? throw new ArgumentNullException(nameof(player2)); _onBackToMenu = onBackToMenu ?? throw new ArgumentNullException(nameof(onBackToMenu)); bool isLoaded = loadedCurrentPlayer != null; if (!isLoaded) AssignUnitIds(); else AssignLoadedArmyOrders(); _p1AllUnits = new List<IUnit>(_player1.Army!.Units); _p2AllUnits = new List<IUnit>(_player2.Army!.Units); _p1InitialOrder = new List<IUnit>(_player1.Army!.Units); _p2InitialOrder = new List<IUnit>(_player2.Army!.Units); _p1UnitImages = BuildUnitImageDict(_p1InitialOrder, flip: false); _p2UnitImages = BuildUnitImageDict(_p2InitialOrder, flip: true); SubscribeObserversToAllUnits(); _player1.Army!.UnitAdded += unit => EnsureObserversSubscribed([unit]); _player2.Army!.UnitAdded += unit => EnsureObserversSubscribed([unit]); _battle = new Battle(player1, player2); _battle.SuppressStatusDisplay = true; _invoker = new CommandInvoker(); if (isLoaded) _battle.SetupLoadedGame(loadedCurrentPlayer!, loadedRoundNumber, loadedGameMode!); // ── controls ────────────────────────────────────────────────────────── _turnLabel = new Eto.Forms.Label { TextAlignment = Eto.Forms.TextAlignment.Center, Font = new Eto.Drawing.Font(SystemFont.Bold, 12), TextColor = Eto.Drawing.Colors.White }; BackgroundColor = Eto.Drawing.Colors.White; _logArea = new Eto.Forms.TextArea { ReadOnly = true, Wrap = true, Font = new Eto.Drawing.Font(FontFamilies.Monospace, 8) }; _p1ColumnHost = new Eto.Forms.Panel(); _p2ColumnHost = new Eto.Forms.Panel(); _battleFieldHost = new Eto.Forms.Panel { BackgroundColor = Eto.Drawing.Colors.White }; _makeRoundBtn = new Eto.Forms.Button { Text = "⚔ Сделать ход" }; _playToEndBtn = new Eto.Forms.Button { Text = "⚡ До конца" }; _undoBtn = new Eto.Forms.Button { Text = "↩ Отменить [Ctrl+X]" }; _redoBtn = new Eto.Forms.Button { Text = "↪ Повторить [Ctrl+Z]" }; _saveBtn = new Eto.Forms.Button { Text = "💾 Сохранить" }; _exitBtn = new Eto.Forms.Button { Text = "Выйти в меню" }; _formationDropDown = new Eto.Forms.DropDown { Width = 155 }; _formationDropDown.Items.Add("Мост (1 на 1)"); _formationDropDown.Items.Add("Стенка на стенку"); _formationDropDown.Items.Add("3 на 3"); _formationDropDown.SelectedIndex = isLoaded ? FormationIndex(loadedGameMode!) : 0; _formationDropDown.SelectedIndexChanged += (_, _) => OnFormationChanged(); _makeRoundBtn.Click += (_, _) => OnMakeRound(); _playToEndBtn.Click += (_, _) => OnPlayToEnd(); _undoBtn.Click += (_, _) => OnUndo(); _redoBtn.Click += (_, _) => OnRedo(); _saveBtn.Click += (_, _) => OnSave(); _exitBtn.Click += (_, _) => ExitToMenu(); BuildLayout(); RefreshAll(); if (isLoaded) { AppendLog($"=== ИГРА ЗАГРУЖЕНА: {_player1.Name} vs {_player2.Name} ===\n"); AppendLog($"Раунд: {_battle.CurrentRound + 1}, ход: {_battle.CurrentPlayer.Name}\n\n"); } else { AppendLog($"=== БОЙ НАЧАЛСЯ: {_player1.Name} vs {_player2.Name} ===\n"); AppendLog($"Первый ход: {_battle.CurrentPlayer.Name}\n\n"); } } // ── layout ──────────────────────────────────────────────────────────────── private void BuildLayout() { var turnPanel = new Eto.Forms.Panel { Padding = new Eto.Drawing.Padding(8, 6), BackgroundColor = Eto.Drawing.Color.FromArgb(50, 100, 180), Content = _turnLabel }; _logArea.Height = 200; var logScrollable = new Eto.Forms.Scrollable { Content = _logArea, Height = 200, ExpandContentWidth = true, ExpandContentHeight = true }; // кнопки по центру var buttonsRow = new Eto.Forms.TableLayout { Padding = new Eto.Drawing.Padding(0, 6), Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Horizontal, Spacing = 6, Items = { _formationDropDown, _makeRoundBtn, _playToEndBtn, _undoBtn, _redoBtn, _saveBtn, _exitBtn } }, false), new Eto.Forms.TableCell(new Eto.Forms.Panel(), true) ) } }; // центральная область: шапка + поле + лог + кнопки var centerArea = new Eto.Forms.TableLayout { Spacing = new Eto.Drawing.Size(0, 2), Rows = { new Eto.Forms.TableRow(turnPanel), new Eto.Forms.TableRow(_battleFieldHost) { ScaleHeight = true }, new Eto.Forms.TableRow(logScrollable), new Eto.Forms.TableRow(buttonsRow) } }; // колонки занимают всю высоту вместе с центром Content = new Eto.Forms.TableLayout { Spacing = new Eto.Drawing.Size(3, 0), Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(_p1ColumnHost, false), new Eto.Forms.TableCell(centerArea, true), new Eto.Forms.TableCell(_p2ColumnHost, false) ) { ScaleHeight = true } } }; } // ── refresh ─────────────────────────────────────────────────────────────── private void RefreshAll() { UpdateTurnLabel(); UpdateColumns(); UpdateBattleField(); UpdateButtonStates(); } private void UpdateTurnLabel() { var cur = _battle.CurrentPlayer; int p1Alive = _player1.Army!.Units.Count(u => u.IsAlive); int p2Alive = _player2.Army!.Units.Count(u => u.IsAlive); _turnLabel.Text = $"ХОД: {cur.Name.ToUpper()} | " + $"{_player1.Name}: {p1Alive} юн. | " + $"{_player2.Name}: {p2Alive} юн. | " + $"Раунд: {_battle.CurrentRound + 1}"; _turnLabel.TextColor = Eto.Drawing.Colors.White; } // ── unit columns ────────────────────────────────────────────────────────── private void UpdateColumns() { SyncUnitTracking(); _p1ColumnHost.Content = BuildColumn(_player1, _p1AllUnits, isLeft: true); _p2ColumnHost.Content = BuildColumn(_player2, _p2AllUnits, isLeft: false); } private void SyncUnitTracking() { // Сначала убираем юниты, которые живы, но вышли из армии через undo // (мёртвые — с IsAlive==false — остаются: они показываются как [ПАЛ В БОЮ]) PurgeUndoneUnits(_p1AllUnits, _player1.Army?.Units ?? []); PurgeUndoneUnits(_p2AllUnits, _player2.Army?.Units ?? []); AssignOrdersToNewUnits(_p1AllUnits, _player1.Army?.Units ?? []); AssignOrdersToNewUnits(_p2AllUnits, _player2.Army?.Units ?? []); RebuildUnitList(_p1AllUnits, _player1.Army?.Units ?? []); RebuildUnitList(_p2AllUnits, _player2.Army?.Units ?? []); // Подписываем наблюдателей к любым новым юнитам (клонам) EnsureObserversSubscribed(_p1AllUnits); EnsureObserversSubscribed(_p2AllUnits); } private void PurgeUndoneUnits(List<IUnit> allUnits, List<IUnit> currentArmy) { var inArmy = new HashSet<IUnit>(currentArmy); for (int i = allUnits.Count - 1; i >= 0; i--) { var u = allUnits[i]; if (u.IsAlive && !inArmy.Contains(u)) { _subscribedUnits.Remove(u); // разрешить повторную подписку при redo allUnits.RemoveAt(i); } } } private static void AssignOrdersToNewUnits(List<IUnit> allUnits, List<IUnit> currentArmy) { int next = allUnits.Count + 1; foreach (var u in currentArmy) { if (!allUnits.Contains(u) && u.ArmyOrder == 0) u.SetArmyOrder(next++); } } private static void RebuildUnitList(List<IUnit> allUnits, List<IUnit> currentArmy) { // Новые юниты (клоны) вставляются перед первым известным юнитом, стоящим после них в армии. // Мёртвые остаются на своих исходных позициях. foreach (var unit in currentArmy) { if (allUnits.Contains(unit)) continue; int armyIdx = currentArmy.IndexOf(unit); int insertAt = allUnits.Count; // по умолчанию — в конец for (int i = armyIdx + 1; i < currentArmy.Count; i++) { int existingIdx = allUnits.IndexOf(currentArmy[i]); if (existingIdx >= 0) { insertAt = existingIdx; break; } } allUnits.Insert(insertAt, unit); } } private Eto.Forms.Control BuildColumn(Player player, List<IUnit> allUnits, bool isLeft) { var stack = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Vertical, Spacing = 2, Padding = new Eto.Drawing.Padding(3) }; // header var header = new Eto.Forms.Panel { Padding = new Eto.Drawing.Padding(4, 3), BackgroundColor = isLeft ? Eto.Drawing.Color.FromArgb(0, 70, 140) : Eto.Drawing.Color.FromArgb(130, 20, 20), Content = new Eto.Forms.Label { Text = player.Name, TextAlignment = Eto.Forms.TextAlignment.Center, TextColor = Eto.Drawing.Colors.White, Font = new Eto.Drawing.Font(SystemFont.Bold, 9) } }; stack.Items.Add(header); var opponentArmy = isLeft ? _player2.Army : _player1.Army; var activeUnits = GetActiveUnits(player.Army, opponentArmy); string playerPrefix = isLeft ? "1" : "2"; foreach (var unit in allUnits) stack.Items.Add(BuildUnitRow(unit, $"{playerPrefix}_{unit.ArmyOrder}", activeUnits.Contains(unit))); return new Eto.Forms.Scrollable { Content = stack, Width = 235, ExpandContentWidth = true, ExpandContentHeight = false, BackgroundColor = Eto.Drawing.Color.FromArgb(245, 245, 250) }; } private HashSet<IUnit> GetActiveUnits(IArmy? army, IArmy? opponentArmy) { if (army == null || opponentArmy == null) return new HashSet<IUnit>(); var formation = _battle.CurrentFormation; if (formation is WallToWallFormation) return army.Units .Where(u => u.IsAlive && formation.GetOpponent(u, army, opponentArmy) != null) .ToHashSet(); if (formation is ThreeOnThreeFormation) { // Используем тот же список живых, что и игровая логика (army.Units) var alive = army.Units.Where(u => u.IsAlive).ToList(); var opAlive = opponentArmy.Units.Where(u => u.IsAlive).ToList(); int numLines = alive.Count == 0 || opAlive.Count == 0 ? 3 : Math.Max(1, Math.Min(3, Math.Min(alive.Count, opAlive.Count))); var groups = SplitIntoGroups(alive, numLines); var active = new HashSet<IUnit>(); foreach (var grp in groups) { var front = grp.FirstOrDefault(u => u.IsAlive); if (front != null) active.Add(front); } return active; } // Bridge: только первый живой юнит var first = army.Units.FirstOrDefault(u => u.IsAlive); return first != null ? new HashSet<IUnit> { first } : new HashSet<IUnit>(); } private Eto.Forms.Control BuildUnitRow(IUnit unit, string label, bool isActive) { bool dead = !unit.IsAlive; bool isClone = unit.IsClone; var bg = dead ? Eto.Drawing.Color.FromArgb(220, 220, 220) : isActive ? Eto.Drawing.Color.FromArgb(255, 245, 180) : isClone ? Eto.Drawing.Color.FromArgb(200, 240, 200) : Eto.Drawing.Colors.White; var fg = dead ? Eto.Drawing.Color.FromArgb(140, 140, 140) : isActive ? Eto.Drawing.Color.FromArgb(120, 80, 0) : isClone ? Eto.Drawing.Color.FromArgb(0, 100, 0) : Eto.Drawing.Color.FromArgb(30, 30, 30); var sb = new StringBuilder(); string cloneTag = isClone ? " [КЛОН]" : ""; sb.AppendLine($"{label} {GetSymbol(unit)} {unit.Name}{cloneTag}"); if (dead) { sb.Append(" [ПАЛ В БОЮ]"); } else { int pct = unit.MaxHealth > 0 ? unit.CurrentHealth * 100 / unit.MaxHealth : 0; sb.AppendLine($" HP: {unit.CurrentHealth}/{unit.MaxHealth} {TextBar(pct, 8)}"); sb.AppendLine($" ATK:{unit.Attack} DEF:{unit.Defense} COST:{unit.Cost}"); if (unit.SpecialAbility is ArrowAbility ar) sb.AppendLine($" Лук: r={ar.Range} p={ar.Power}"); else if (unit.SpecialAbility is HealAbility he) sb.AppendLine($" Лечение: r={he.Range} h={he.HealPower}"); else if (unit.SpecialAbility is CloneAbility cl) sb.AppendLine($" Клон: r={cl.Range} {cl.CloneProbability}%"); else if (unit.SpecialAbility is SquireAbility) sb.AppendLine(" Баффер"); } var textLabel = new Eto.Forms.Label { Text = sb.ToString().TrimEnd(), Wrap = Eto.Forms.WrapMode.Word, TextColor = fg, Font = new Eto.Drawing.Font(FontFamilies.Monospace, 8) }; if (unit is IBuffable buffable && buffable.HasBuffs()) { return new Eto.Forms.Panel { Padding = new Eto.Drawing.Padding(5, 3), BackgroundColor = bg, Content = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Vertical, Spacing = 2, Items = { textLabel, BuildBuffIcons(buffable.GetActiveBuffs()) } } }; } return new Eto.Forms.Panel { Padding = new Eto.Drawing.Padding(5, 3), BackgroundColor = bg, Content = textLabel }; } // ── battle field ────────────────────────────────────────────────────────── private void UpdateBattleField() { if (_battle.CurrentFormation is WallToWallFormation) { _battleFieldHost.Content = BuildWallToWallBattlefield(); return; } if (_battle.CurrentFormation is ThreeOnThreeFormation) { _battleFieldHost.Content = BuildThreeOnThreeBattlefield(); return; } const int unitSize = 80; const int maxVisible = 7; var p1Alive = _player1.Army?.Units.Where(u => u.IsAlive).ToList() ?? new List<IUnit>(); var p2Alive = _player2.Army?.Units.Where(u => u.IsAlive).ToList() ?? new List<IUnit>(); _battleFieldHost.Content = new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(BuildQueueSide(p1Alive, isLeft: true, unitSize, maxVisible), true), new Eto.Forms.TableCell(BuildQueueSide(p2Alive, isLeft: false, unitSize, maxVisible), true) ) { ScaleHeight = true } } }; } private Eto.Forms.Control BuildWallToWallBattlefield() { const int unitSize = 80; var p1Units = _player1.Army?.Units.Where(u => u.IsAlive).ToList() ?? new List<IUnit>(); var p2Units = _player2.Army?.Units.Where(u => u.IsAlive).ToList() ?? new List<IUnit>(); var rows = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Vertical, Spacing = 6, Padding = new Eto.Drawing.Padding(8), HorizontalContentAlignment = Eto.Forms.HorizontalAlignment.Stretch }; int maxRows = Math.Max(p1Units.Count, p2Units.Count); for (int i = 0; i < maxRows; i++) { var p1Cell = i < p1Units.Count ? MakeWallUnitCell(p1Units[i], flip: false, unitSize) : (Eto.Forms.Control)new Eto.Forms.Panel { Size = new Eto.Drawing.Size(unitSize, unitSize) }; var p2Cell = i < p2Units.Count ? MakeWallUnitCell(p2Units[i], flip: true, unitSize) : (Eto.Forms.Control)new Eto.Forms.Panel { Size = new Eto.Drawing.Size(unitSize, unitSize) }; rows.Items.Add(new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(p1Cell, false), new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(p2Cell, false) ) } }); } return new Eto.Forms.Scrollable { Content = rows, ExpandContentWidth = true, ExpandContentHeight = false }; } private Eto.Forms.Control BuildThreeOnThreeBattlefield() { const int numRows = 3; const int gap = 2; const int rowGap = 8; const int padY = 8; const int maxCell = 80; EnsureThreeOnThreeGroupsReady(); // Snapshot groups so Paint lambda sees a stable list var p1Groups = _3x3P1Groups.Select(g => g.ToList()).ToList(); var p2Groups = _3x3P2Groups.Select(g => g.ToList()).ToList(); // Cell size based on largest group across both sides int maxGroupSize = 1; foreach (var g in p1Groups) maxGroupSize = Math.Max(maxGroupSize, g.Count); foreach (var g in p2Groups) maxGroupSize = Math.Max(maxGroupSize, g.Count); int canvasH = numRows * maxCell + (numRows - 1) * rowGap + 2 * padY; var canvas = new Eto.Forms.Drawable { Height = canvasH }; canvas.Paint += (sender, e) => { int W = ((Eto.Forms.Drawable)sender!).Width; if (W <= 0) return; int halfW = W / 2; int cell = Math.Max(16, Math.Min(maxCell, (halfW - Math.Max(0, maxGroupSize - 1) * gap) / maxGroupSize)); int drawRows = Math.Max(p1Groups.Count, p2Groups.Count); for (int g = 0; g < drawRows; g++) { float y = padY + g * (cell + rowGap); // --- P1: alive units compress toward center (rightmost = front) --- var grp1 = g < p1Groups.Count ? p1Groups[g] : new List<IUnit>(); var alive1 = grp1.Where(u => u.IsAlive).ToList(); int cnt1 = alive1.Count; if (cnt1 > 0) { float startX = halfW - cnt1 * cell - Math.Max(0, cnt1 - 1) * gap; for (int i = 0; i < cnt1; i++) { var unit = alive1[cnt1 - 1 - i]; // front unit rightmost var img = GetUnitImage(unit, isP2: false); if (img != null) e.Graphics.DrawImage(img, startX + i * (float)(cell + gap), y, cell, cell); } } // --- P2: alive units compress toward center (leftmost = front) --- var grp2 = g < p2Groups.Count ? p2Groups[g] : new List<IUnit>(); var alive2 = grp2.Where(u => u.IsAlive).ToList(); int cnt2 = alive2.Count; if (cnt2 > 0) { float startX = halfW + gap; for (int i = 0; i < cnt2; i++) { var unit = alive2[i]; var img = GetUnitImage(unit, isP2: true); if (img != null) e.Graphics.DrawImage(img, startX + i * (float)(cell + gap), y, cell, cell); } } } }; return canvas; } // ── 3×3 group management ────────────────────────────────────────────────── // Пересчитываем группы при каждом обновлении: берём всех живых в исходном порядке // и равномерно делим на 3 линии. Это гарантирует, что линии всегда сбалансированы // и юниты автоматически подтягиваются вперёд при гибели товарищей. private void EnsureThreeOnThreeGroupsReady() { var alive1 = _player1.Army?.Units.Where(u => u.IsAlive).ToList() ?? []; var alive2 = _player2.Army?.Units.Where(u => u.IsAlive).ToList() ?? []; int numRows = alive1.Count == 0 || alive2.Count == 0 ? 3 : Math.Max(1, Math.Min(3, Math.Min(alive1.Count, alive2.Count))); _3x3P1Groups = SplitIntoGroups(alive1, numRows); _3x3P2Groups = SplitIntoGroups(alive2, numRows); } private static List<List<IUnit>> SplitIntoGroups(List<IUnit> alive, int maxGroups) { if (alive.Count == 0) return new List<List<IUnit>>(); int k = Math.Min(maxGroups, alive.Count); int baseSize = alive.Count / k; int extra = alive.Count % k; var groups = new List<List<IUnit>>(); int idx = 0; for (int g = 0; g < k; g++) { int size = baseSize + (g < extra ? 1 : 0); var group = new List<IUnit>(); for (int i = 0; i < size; i++) group.Add(alive[idx++]); groups.Add(group); } return groups; } private Eto.Drawing.Image? GetUnitImage(IUnit unit, bool isP2) { var dict = isP2 ? _p2UnitImages : _p1UnitImages; if (dict.TryGetValue(unit, out var cached)) return cached; try { var path = UnitImageProvider.GetImagePath(unit); if (!System.IO.File.Exists(path)) return null; var bmp = new Eto.Drawing.Bitmap(path); var img = isP2 ? (Eto.Drawing.Image)FlipHorizontal(bmp) : bmp; dict[unit] = img; return img; } catch { return null; } } private static Dictionary<IUnit, Eto.Drawing.Image?> BuildUnitImageDict( List<IUnit> units, bool flip) { var dict = new Dictionary<IUnit, Eto.Drawing.Image?>(); foreach (var unit in units) { try { var path = UnitImageProvider.GetImagePath(unit); if (System.IO.File.Exists(path)) { var bmp = new Eto.Drawing.Bitmap(path); dict[unit] = flip ? (Eto.Drawing.Image?)FlipHorizontal(bmp) : bmp; } else dict[unit] = null; } catch { dict[unit] = null; } } return dict; } private static Eto.Forms.Control MakeWallUnitCell(IUnit unit, bool flip, int unitSize) { Eto.Drawing.Image? img = null; try { var path = UnitImageProvider.GetImagePath(unit); if (System.IO.File.Exists(path)) { var bmp = new Eto.Drawing.Bitmap(path); img = flip ? FlipHorizontal(bmp) : bmp; } } catch { } return new Eto.Forms.ImageView { Image = img, Size = new Eto.Drawing.Size(unitSize, unitSize) }; } private static Eto.Forms.Control BuildQueueSide(List<IUnit> units, bool isLeft, int unitSize, int maxVisible) { if (units.Count == 0) return new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow(new Eto.Forms.Panel()) { ScaleHeight = true }, new Eto.Forms.TableRow(new Eto.Forms.Label { Text = "ПОБЕЖДЁН", TextAlignment = Eto.Forms.TextAlignment.Center, TextColor = Eto.Drawing.Color.FromArgb(180, 40, 40), Font = new Eto.Drawing.Font(SystemFont.Bold, 14) }) } }; const int spc = 2; const int pad = 2; int cellW = unitSize + spc; int h = unitSize + pad * 2; // Pre-load images (flip P2 once here, not on every repaint) var imgs = units.Select(u => { try { var path = UnitImageProvider.GetImagePath(u); if (!System.IO.File.Exists(path)) return (Eto.Drawing.Image?)null; var bmp = new Eto.Drawing.Bitmap(path); return isLeft ? (Eto.Drawing.Image)bmp : FlipHorizontal(bmp); } catch { return (Eto.Drawing.Image?)null; } }).ToList(); var labelFont = new Eto.Drawing.Font(Eto.Drawing.FontFamilies.Monospace, 8f, Eto.Drawing.FontStyle.Bold); var labelColor = Eto.Drawing.Color.FromArgb(120, 120, 140); var canvas = new Eto.Forms.Drawable { Height = h }; canvas.Paint += (sender, e) => { int W = ((Eto.Forms.Drawable)sender!).Width; if (W <= 0) return; // How many sprites fit? Reserve ~30 px for "+N" label when clipping occurs. int allFit = (W - pad) / cellW; int visible, hidden; if (allFit >= units.Count) { visible = units.Count; hidden = 0; } else { visible = Math.Max(1, (W - pad - 30) / cellW); hidden = units.Count - visible; } if (isLeft) { // P1: units[0] = front/active — anchored to RIGHT edge, always visible. // Back units are drawn to the left and may be clipped if strip overflows. int x = W - pad; for (int i = 0; i < visible && i < imgs.Count; i++) { x -= unitSize; if (imgs[i] is { } img) e.Graphics.DrawImage(img, (float)x, (float)pad, (float)unitSize, (float)unitSize); x -= spc; } if (hidden > 0) e.Graphics.DrawText(labelFont, labelColor, 2f, (float)pad, $"+{hidden}"); } else { // P2: units[0] = front/active — anchored to LEFT edge, always visible. int x = pad; for (int i = 0; i < visible && i < imgs.Count; i++) { if (imgs[i] is { } img) e.Graphics.DrawImage(img, (float)x, (float)pad, (float)unitSize, (float)unitSize); x += cellW; } if (hidden > 0) e.Graphics.DrawText(labelFont, labelColor, (float)x, (float)pad, $"+{hidden}"); } }; return new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow(new Eto.Forms.Panel()) { ScaleHeight = true }, new Eto.Forms.TableRow(new Eto.Forms.TableCell(canvas, true)) } }; } // ── actions ─────────────────────────────────────────────────────────────── public void TriggerUndo() => OnUndo(); public void TriggerRedo() => OnRedo(); private void OnMakeRound() { if (_battleEnded) return; ClearLog(); var p1Before = new HashSet<IUnit>(_player1.Army?.Units ?? []); var p2Before = new HashSet<IUnit>(_player2.Army?.Units ?? []); var cmd = new MakeRoundCommand(_battle); CaptureConsole(() => _invoker.Execute(cmd)); RefreshAll(); AppendClonePositionLog(_p1AllUnits, p1Before, "1"); AppendClonePositionLog(_p2AllUnits, p2Before, "2"); CheckEnd(cmd.BattleEnded, cmd.IsDraw, cmd.Winner); } private void AppendClonePositionLog(List<IUnit> allUnits, HashSet<IUnit> before, string prefix) { foreach (var u in allUnits) { if (u.IsAlive && u.IsClone && !before.Contains(u)) AppendLog($"→ Клон добавлен в колонку: {prefix}_{u.ArmyOrder} {u.Name}\n"); } } private void OnPlayToEnd() { if (_battleEnded) return; ClearLog(); var cmd = new PlayToEndCommand(_battle); CaptureConsole(() => _invoker.Execute(cmd)); _battleEnded = true; RefreshAll(); string resultMsg = cmd.IsDraw ? "Ничья!" : $"{cmd.Winner?.Name} победил!"; AppendLog(cmd.IsDraw ? "\n=== НИЧЬЯ ===\n" : $"\n=== {cmd.Winner?.Name?.ToUpper()} ПОБЕДИЛ! ===\n"); if (ShowPlayToEndResultDialog(resultMsg)) { // Пользователь выбрал «Отменить» — восстанавливаем игру OnUndo(); return; } // Пользователь выбрал «Выйти в меню» if (ObserverSettings.Current.EnableDamageLogging) { var ask = Eto.Forms.MessageBox.Show( "Хотите посмотреть лог урона?", "Лог урона", Eto.Forms.MessageBoxButtons.YesNo); if (ask == Eto.Forms.DialogResult.Yes) ShowLogDialog(); } _onBackToMenu(); } // Возвращает true если пользователь хочет отменить действие private bool ShowPlayToEndResultDialog(string resultMessage) { bool wantsUndo = false; var msgLabel = new Eto.Forms.Label { Text = resultMessage, Font = new Eto.Drawing.Font(SystemFont.Bold, 13), TextAlignment = Eto.Forms.TextAlignment.Center }; var undoBtn = new Eto.Forms.Button { Text = "↩ Отменить" }; var menuBtn = new Eto.Forms.Button { Text = "Выйти в меню" }; var dlg = new Eto.Forms.Dialog { Title = "Конец битвы", Resizable = false, Content = new Eto.Forms.TableLayout { Padding = new Eto.Drawing.Padding(20, 16), Spacing = new Eto.Drawing.Size(0, 14), Rows = { new Eto.Forms.TableRow(msgLabel), new Eto.Forms.TableRow(new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(undoBtn), new Eto.Forms.TableCell(menuBtn)) } }) } } }; undoBtn.Click += (_, _) => { wantsUndo = true; dlg.Close(); }; menuBtn.Click += (_, _) => dlg.Close(); dlg.ShowModal(); return wantsUndo; } private void OnUndo() { if (!_invoker.CanUndo) { ClearLog(); AppendLog("[Нет действий для отмены]\n"); return; } ClearLog(); CaptureConsole(() => _invoker.Undo()); _battleEnded = false; AppendLog("--- Действие отменено ---\n"); RefreshAll(); SyncFormationDropdown(); } private void OnRedo() { if (!_invoker.CanRedo) { ClearLog(); AppendLog("[Нет действий для повтора]\n"); return; } ClearLog(); IGameCommand? cmd = null; CaptureConsole(() => { cmd = _invoker.Redo(); }); AppendLog("--- Действие повторено ---\n"); RefreshAll(); SyncFormationDropdown(); if (cmd is MakeRoundCommand mrc) CheckEnd(mrc.BattleEnded, mrc.IsDraw, mrc.Winner); else if (cmd is PlayToEndCommand ptc) { _battleEnded = true; CheckEnd(ptc.BattleEnded, ptc.IsDraw, ptc.Winner); } } private void OnSave() { string? saveName = ShowSaveNameDialog(); if (string.IsNullOrWhiteSpace(saveName)) return; try { var cmd = new SaveGameCommand(_battle, _player1, _player2, saveName.Trim()); CaptureConsole(() => _invoker.Execute(cmd)); } catch (Exception ex) { Eto.Forms.MessageBox.Show($"Ошибка сохранения: {ex.Message}", "Ошибка", Eto.Forms.MessageBoxButtons.OK); } } private string? ShowSaveNameDialog() { string? result = null; string defaultName = $"save_{DateTime.Now:yyyyMMdd_HHmm}"; var nameBox = new Eto.Forms.TextBox { Text = defaultName, Width = 280 }; var okBtn = new Eto.Forms.Button { Text = "Сохранить" }; var cancelBtn = new Eto.Forms.Button { Text = "Отмена" }; var dlg = new Eto.Forms.Dialog { Title = "Сохранить игру", Resizable = false, DefaultButton = okBtn, AbortButton = cancelBtn, Content = new Eto.Forms.TableLayout { Padding = new Eto.Drawing.Padding(14), Spacing = new Eto.Drawing.Size(6, 8), Rows = { new Eto.Forms.TableRow(new Eto.Forms.Label { Text = "Имя файла сохранения:" }), new Eto.Forms.TableRow(nameBox), new Eto.Forms.TableRow(new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(okBtn), new Eto.Forms.TableCell(cancelBtn)) } }) } } }; okBtn.Click += (_, _) => { result = nameBox.Text; dlg.Close(); }; cancelBtn.Click += (_, _) => dlg.Close(); dlg.ShowModal(); return result; } private void OnFormationChanged() { if (_syncingDropdown) return; var newFormation = _formationDropDown.SelectedIndex switch { 1 => (IBattleFormation)new WallToWallFormation(), 2 => new ThreeOnThreeFormation(), _ => new BridgeFormation() }; var cmd = new ChangeFormationCommand(_battle, newFormation); CaptureConsole(() => _invoker.Execute(cmd)); UpdateBattleField(); } private void SyncFormationDropdown() { int idx = _battle.CurrentFormation switch { WallToWallFormation => 1, ThreeOnThreeFormation => 2, _ => 0 }; if (_formationDropDown.SelectedIndex != idx) { _syncingDropdown = true; _formationDropDown.SelectedIndex = idx; _syncingDropdown = false; } } private void CheckEnd(bool ended, bool isDraw, Player? winner) { if (!ended) return; _battleEnded = true; UpdateButtonStates(); if (isDraw) { AppendLog("\n=== НИЧЬЯ ===\n"); ShowEndDialog("Ничья!"); } else { AppendLog($"\n=== {winner?.Name?.ToUpper()} ПОБЕДИЛ! ===\n"); ShowEndDialog($"{winner?.Name} победил!"); } } private void ShowEndDialog(string message) { DisableActionButtons(); if (!ShowBattleEndChoiceDialog(message)) return; // пользователь хочет дочитать лог — выйдет сам через кнопку «Выйти в меню» if (ObserverSettings.Current.EnableDamageLogging) { var ask = Eto.Forms.MessageBox.Show( "Хотите посмотреть лог урона?", "Лог урона", Eto.Forms.MessageBoxButtons.YesNo); if (ask == Eto.Forms.DialogResult.Yes) ShowLogDialog(); } _onBackToMenu(); } // Возвращает true — «Выйти в меню», false — «Читать лог хода» private bool ShowBattleEndChoiceDialog(string resultMessage) { bool goToMenu = true; var msgLabel = new Eto.Forms.Label { Text = resultMessage, Font = new Eto.Drawing.Font(SystemFont.Bold, 13), TextAlignment = Eto.Forms.TextAlignment.Center }; var readLogBtn = new Eto.Forms.Button { Text = "Читать лог хода" }; var menuBtn = new Eto.Forms.Button { Text = "Выйти в меню" }; var dlg = new Eto.Forms.Dialog { Title = "Конец битвы", Resizable = false, Content = new Eto.Forms.TableLayout { Padding = new Eto.Drawing.Padding(20, 16), Spacing = new Eto.Drawing.Size(0, 14), Rows = { new Eto.Forms.TableRow(msgLabel), new Eto.Forms.TableRow(new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(readLogBtn), new Eto.Forms.TableCell(menuBtn)) } }) } } }; readLogBtn.Click += (_, _) => { goToMenu = false; dlg.Close(); }; menuBtn.Click += (_, _) => { goToMenu = true; dlg.Close(); }; dlg.ShowModal(); return goToMenu; } private void ShowLogDialog() { string logText; try { logText = System.IO.File.ReadAllText( System.IO.Path.Combine(AppContext.BaseDirectory, $"damage_log_session_{DamageLogViewer.GetSessionNumber()}.txt")); } catch { logText = "Не удалось прочитать лог."; } var textArea = new Eto.Forms.TextArea { ReadOnly = true, Text = logText, Font = new Eto.Drawing.Font(Eto.Drawing.FontFamilies.Monospace, 8), Size = new Eto.Drawing.Size(700, 450) }; var closeBtn = new Eto.Forms.Button { Text = "Закрыть" }; var dlg = new Eto.Forms.Dialog { Title = "Лог урона", Resizable = true, Content = new Eto.Forms.TableLayout { Padding = new Eto.Drawing.Padding(10), Spacing = new Eto.Drawing.Size(0, 8), Rows = { new Eto.Forms.TableRow(new Eto.Forms.Scrollable { Content = textArea, Size = new Eto.Drawing.Size(700, 450) }) { ScaleHeight = true }, new Eto.Forms.TableRow(new Eto.Forms.TableLayout { Rows = { new Eto.Forms.TableRow( new Eto.Forms.TableCell(new Eto.Forms.Panel(), true), new Eto.Forms.TableCell(closeBtn)) } }) } } }; closeBtn.Click += (_, _) => dlg.Close(); dlg.ShowModal(); } private void UpdateButtonStates() { bool canAct = !_battleEnded; _makeRoundBtn.Enabled = canAct; _playToEndBtn.Enabled = canAct; _undoBtn.Enabled = canAct && _invoker.CanUndo; _redoBtn.Enabled = canAct && _invoker.CanRedo; } private void DisableActionButtons() { _makeRoundBtn.Enabled = false; _playToEndBtn.Enabled = false; _undoBtn.Enabled = false; _redoBtn.Enabled = false; } // ── console capture ─────────────────────────────────────────────────────── private void CaptureConsole(Action action) { var sb = new StringBuilder(); var sw = new StringWriter(sb); var prev = Console.Out; Console.SetOut(sw); try { action(); } finally { Console.SetOut(prev); } var text = sb.ToString(); if (!string.IsNullOrEmpty(text)) AppendLog(text); } private void ClearLog() => _logArea.Text = string.Empty; private void AppendLog(string text) { _logArea.Text += text; _logArea.Selection = new Eto.Forms.Range<int>(_logArea.Text.Length, _logArea.Text.Length); } // ── helpers ─────────────────────────────────────────────────────────────── private static Eto.Forms.Control BuildBuffIcons(List<IBuff> buffs) { const int iconSize = 20; const int gap = 2; const int iconsPerRow = 9; // ~(235 - 16px padding) / 22px per icon var container = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Vertical, Spacing = gap }; var currentRow = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Horizontal, Spacing = gap }; int count = 0; foreach (var buff in buffs) { if (count > 0 && count % iconsPerRow == 0) { container.Items.Add(currentRow); currentRow = new Eto.Forms.StackLayout { Orientation = Eto.Forms.Orientation.Horizontal, Spacing = gap }; } Eto.Drawing.Image? img = null; try { var path = System.IO.Path.Combine( AppContext.BaseDirectory, "Resources", "Buff", buff.Name.ToLower() + ".png"); if (System.IO.File.Exists(path)) img = new Eto.Drawing.Bitmap(path); } catch { } currentRow.Items.Add(new Eto.Forms.ImageView { Image = img, Size = new Eto.Drawing.Size(iconSize, iconSize) }); count++; } if (count > 0) container.Items.Add(currentRow); return container; } private static Eto.Drawing.Bitmap FlipHorizontal(Eto.Drawing.Bitmap source) { var result = new Eto.Drawing.Bitmap(source.Width, source.Height, Eto.Drawing.PixelFormat.Format32bppRgba); using var g = new Eto.Drawing.Graphics(result); g.TranslateTransform(source.Width, 0); g.ScaleTransform(-1f, 1f); g.DrawImage(source, Eto.Drawing.PointF.Empty); return result; } private static string TextBar(int pct, int width) { int filled = Math.Max(0, Math.Min(width, pct * width / 100)); return "[" + new string('█', filled) + new string('░', width - filled) + "]"; } private static int FormationIndex(string gameMode) => gameMode switch { "WallToWallFormation" => 1, "ThreeOnThreeFormation" => 2, _ => 0 }; private static string GetSymbol(IUnit unit) { if (unit is BuffableHeavyInfantry) return "[V]"; if (unit is Game.Models.Units.Adapters.GulyayGorodAdapter) return "[G]"; if (unit.SpecialAbility is ArrowAbility) return "[A]"; if (unit.SpecialAbility is HealAbility) return "[+]"; if (unit.SpecialAbility is CloneAbility) return "[W]"; if (unit is LightInfantry) return "[L]"; return "[?]"; } private void AssignUnitIds() { int id = 1; int p1Order = 1; if (_player1.Army != null) foreach (var u in _player1.Army.Units) { u.SetId(id++); u.SetArmyOrder(p1Order++); } int p2Order = 1; if (_player2.Army != null) foreach (var u in _player2.Army.Units) { u.SetId(id++); u.SetArmyOrder(p2Order++); } } private void AssignLoadedArmyOrders() { int p1Order = 1; if (_player1.Army != null) foreach (var u in _player1.Army.Units) u.SetArmyOrder(p1Order++); int p2Order = 1; if (_player2.Army != null) foreach (var u in _player2.Army.Units) u.SetArmyOrder(p2Order++); } private void SubscribeObserversToAllUnits() { EnsureObserversSubscribed(_p1AllUnits); EnsureObserversSubscribed(_p2AllUnits); } private void ExitToMenu() { if (ObserverSettings.Current.EnableDamageLogging && DamageLogViewer.HasLogFile()) { var ask = Eto.Forms.MessageBox.Show( "Хотите посмотреть лог урона?", "Лог урона", Eto.Forms.MessageBoxButtons.YesNo); if (ask == Eto.Forms.DialogResult.Yes) ShowLogDialog(); } _onBackToMenu(); } private void EnsureObserversSubscribed(List<IUnit> units) { foreach (var unit in units) { if (_subscribedUnits.Contains(unit)) continue; _subscribedUnits.Add(unit); if (ObserverSettings.Current.EnableDamageLogging) unit.Subscribe(new DamageLoggerObserver()); if (ObserverSettings.Current.EnableDeathBeep) unit.Subscribe(new DeathBeepObserver()); } } }