Игра на WPF, переносим код WinForms в MVVM

Saint

Хочу реализовать: Button расположены квадратом (как 2-мерный массив NxN). При клике на кнопку поворачиваются все кнопки в одной строке и в одном столбце. Число N настраиваемое. Начал сначала всё делать в MainWindow.xaml.cs, посоветовали всё сделать нормально и использовать MVVM. Код MainWindow.xaml.cs:

public partial class MainWindow : Window
{ public MainWindow() { InitializeComponent(); } private Button[,] CreateButtons(int quantity) { Form.Rows = quantity; Form.Columns = quantity; Button[,] buttons = new Button[quantity, quantity]; for (int i = 0; i < quantity; i++) { for (int j = 0; j < quantity; j++) { buttons[i, j] = new Button(); buttons[i, j].Width = 100; buttons[i, j].Height = 20; buttons[i, j].Margin = new Thickness(5,80,0,0); buttons[i, j].Click += new RoutedEventHandler(new_button_click); } } return buttons; } void new_button_click(object sender, RoutedEventArgs e) { Button btn = sender as Button; if (btn != null) { var rotateTransform = btn.RenderTransform as RotateTransform; var transform = new RotateTransform(90 + (rotateTransform == null ? 0 : rotateTransform.Angle)); transform.CenterX = 50; transform.CenterY = 10; btn.RenderTransform = transform; } } private void AddToWrapPanel(int quantity, Button[,] buttons) { for (int i = 0; i < quantity; i++) for (int j = 0; j < quantity; j++) { Form.Children.Add(buttons[i, j]); } } private int GetQuantityButtons() { ComboBoxItem item = (ComboBoxItem)comboBox1.SelectedItem; int count = int.Parse((string)item.Content); return count; } private void СreateButton_Click(object sender, RoutedEventArgs e) { if (Form.Children.Count > 0) Form.Children.Clear(); int count = GetQuantityButtons(); Button[,] buttons = CreateButtons(count); AddToWrapPanel(count, buttons); }
}

Теперь начинаю всё переносить.

XAML:

<window x:class="Di.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:user="clr-namespace:Di" title="Сейф" height="715.6" width="840" left="250" top="10" background="Silver" resizemode="CanMinimize" textoptions.textformattingmode="Display" icon="Resources/Icon1.ico">
<window.datacontext> </window.datacontext>
<grid> <itemscontrol margin="0,30,0,0"> <uniformgrid x:name="Form"> <wrappanel name="wrapPanel" background="#FFF2F2F2"> </wrappanel></uniformgrid></itemscontrol> <button content="Старт" height="23" horizontalalignment="Left" margin="457,12,0,0" name="createButton" verticalalignment="Top" width="75" click="СreateButton_Click" command="{Binding Seter}"> <combobox height="23" horizontalalignment="Left" margin="368,12,0,0" name="comboBox1" verticalalignment="Top" width="74" selectedindex="0" rendertransformorigin="0.5,0.739"> <comboboxitem content="{Binding Three}"> <comboboxitem content="{Binding Four}"> <comboboxitem content="{Binding Five}"> <comboboxitem content="{Binding Six}"> </comboboxitem></comboboxitem></comboboxitem></comboboxitem></combobox> </button></grid></window>

Код VM:

public class MainWindowModel
{ public int Three { get; set; } public int Four { get; set; } public int Five { get; set; } public int Six { get; set; } public string Lvl { get; set; } public MainWindowModel() { Three = 3; Four = 4; Five = 5; Six = 6; Lvl = "Сложность (3 - 6):"; } private ICommand _seter; public ICommand Seter { get { return _seter ?? (_seter = new RelayCommand(() => { // действие при вызове команды })); } }
}

Пока только так.. Помогите, пожалуйста, перенести и доделать задуманные мной моменты. Например: как в VM, обслуживающем кнопку, завести свойство RotationAngle? Как при клике по "создать" с генерировать массив из кнопок и потом работать с ними? Как обращаться к UniformGrid и связать кол-во строк и столбцов с выбранным int в combobox?

1 ответ

Saint

Давайте пойдём сначала. Построим VM. Нам пригодится базовый класс для VM, в котором будет имплементация INotifyPropertyChanged:

class VM : INotifyPropertyChanged
{ protected bool Set<t>(ref T field, T value, [CallerMemberName] string propertyName = null) { if (EqualityComparer<t>.Default.Equals(field, value)) return false; field = value; RaisePropertyChanged(propertyName); return true; } protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); public event PropertyChangedEventHandler PropertyChanged;
}
</t></t>

(Если вы пользуетесь каким-нибудь MVVM-фреймворком, то аналогичный базовый класс у вас уже может быть определён.)

Теперь VM для одной клетки. Что нам нужно знать? Угол поворота — сделаем из него свойство с INPC. Строку и столбец — эти свойства неизменяемые. И команда, которая будет вызываться при активации клетки. (Она тоже неизменяемая.)

Действие, которое будет выполняться при нажатии на клетку, сама клетка выполнить не может, так как поворот происходит у многих клеток. Поэтому реакцию на действие передадим «сверху» в качестве параметра. Получаем такой вот код:

class CellVM : VM
{ public CellVM(int row, int column, Action<int, int=""> onActivate) { Row = row; Column = column; Activate = new RelayCommand(() => onActivate(row, column)); } ****** rotationAngle = 0; public ****** RotationAngle { get { return rotationAngle; } set { Set(ref rotationAngle, value); } } public int Row { get; } public int Column { get; } public ICommand Activate { get; }
}
</int,>

Следующая VM — вся доска. Ей придётся и принимать решение о вращении клеток. Какие нам тут нужны данные? Ширина и высота нужны, и при изменении нужно пересоздать массив клеток. Нужны сами клетки, и поскольку клетки у нас будут подменяться только как целое, берём не ObservableCollection<CellVM>, а просто IEnumerable<CellVM>. Наружу выставлять квадратный массив нельзя, никто не умеет к нему привязываться. Поэтому выставим все клетки, «слитые» в одну общую последовательность.

class BoardVM : VM
{ int width; public int Width { get { return width; } set { if (Set(ref width, value)) { GenerateCells(); } } } int height; public int Height { get { return height; } set { if (Set(ref height, value)) { GenerateCells(); } } } CellVM[,] cells; public IEnumerable<cellvm> AllCells => cells.Cast<cellvm>();
</cellvm></cellvm>

Далее, при изменении количества строк или столбцов нам нужно перегенерировать клетки.

void GenerateCells() { var cells = new CellVM[width, height]; for (int row = 0; row < height; row++) for (int column = 0; column < width; column++) cells[column, row] = new CellVM(row, column, OnCellActivate); ShuffleAngles(cells); // отбрасываем существующие клетки this.cells = cells; RaisePropertyChanged(nameof(AllCells)); }

... и установить им случайный начальный угол:

static Random random = new Random(); void ShuffleAngles(CellVM[,] cells) { for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) cells[x, y].RotationAngle = random.Next(4) * 90; }

Теперь функция, которая вызывается при активации клетки. Нам нужно повернуть все клетки в том же столбце и той же строке. Во втором цикле пропускем уже один раз повёрнутую клетку.

void OnCellActivate(int row0, int column0) { for (int row = 0; row < height; row++) Rotate(cells[column0, row]); for (int column = 0; column < width; column++) if (column != column0) Rotate(cells[column, row0]); } void Rotate(CellVM cellVM) { cellVM.RotationAngle = (cellVM.RotationAngle + 90) % 360; }
}

Окей, с VM более-менее ясно. Переходим к View.

Нам понадобится ItemsControl, т. к. мы хотим показать последовательность элементов. У нас последовательность элементов содержится в свойстве AllCells.

Далее, нам нужно, чтобы клетки укладывались в UniformGrid. Выберем в качестве носителя UniformGrid, заодно привяжем количество строк и столбцов:

<itemscontrol.itemspanel> <itemspaneltemplate> <uniformgrid isitemshost="True" rows="{Binding Width}" columns="{Binding Height}"> </uniformgrid></itemspaneltemplate> </itemscontrol.itemspanel>

Дальше, как показывать отдельную клетку? Вы хотите Button, пускай. Пишем DataTemplate.

<itemscontrol.itemtemplate> <datatemplate datatype="{x:Type vm:CellVM}"> </datatemplate></itemscontrol.itemtemplate>

Запустив программу, видим, что кнопка слишком прилегает к границам клетки, поэтому даём ей Margin="10". Теперь, нам нужно как-то обозначить, где верх и где низ. Для этого нарисуем стрелочку вверх (но вам придётся сделать что-то покрасивее). Стрелочку будем поворачивать на угол из привязки:

<itemscontrol.itemtemplate> <datatemplate datatype="{x:Type vm:CellVM}"> <button command="{Binding Activate}" margin="10" padding="10"> <path data="M 0,1 L 1,0 L 2,1 M 1,2 L 1,0" stroke="Black" stretch="Uniform" rendertransformorigin="0.5,0.5"> <path.rendertransform> <rotatetransform angle="{Binding RotationAngle}"> </rotatetransform></path.rendertransform> </path> </button> </datatemplate> </itemscontrol.itemtemplate>

Вроде бы всё.

Теперь нужно ещё задать размеры поля.

Для этого, по-хорошему, нужно завести другое окно (и показывать его только в самом начале игры), потому что изменять размер поля во время игры как-то неправильно. Но в нашем быстром прототипе мы закроем на это глаза. (А вам потом придётся таки переделать.)

Итак, нам нужна информация о том, сколько у нас возможно строк и столбцов. Возвращаемся в VM и заводим класс:

static class GameInfo
{ static public IEnumerable<int> PossibleColumnNumber { get; } = new[] { 3, 4, 5, 6 }; static public IEnumerable<int> PossibleRowNumber { get; } = new[] { 3, 4, 5, 6 };
}
</int></int>

Для красоты, нам нужно инициализировать значения в BoardVM валидным числом. Находим и меняем строки:

int width = GameInfo.PossibleColumnNumber.Min();

и

int height = GameInfo.PossibleRowNumber.Min();

Теперь View. Дописываем два комбобокса и метки к ним:

<stackpanel orientation="Horizontal" grid.row="1" horizontalalignment="Center"> <label target="{Binding ElementName=ColumnChooser}">Columns: </label> <combobox name="ColumnChooser" selecteditem="{Binding Width}" itemssource="{x:Static vm:GameInfo.PossibleColumnNumber}"> <label target="{Binding ElementName=RowChooser}">Rows:</label> </combobox></stackpanel>

Весь MainWindow.xaml:

<window x:class="View.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:ignorable="d" title="Test" height="350" width="350"> <grid d:datacontext="{d:DesignInstance Type=vm:BoardVM, IsDesignTimeCreatable=False}"> <grid.rowdefinitions> <rowdefinition height="*"> <rowdefinition height="Auto"> </rowdefinition></rowdefinition></grid.rowdefinitions> <itemscontrol itemssource="{Binding AllCells}"> <itemscontrol.itemspanel> <itemspaneltemplate> <uniformgrid isitemshost="True" rows="{Binding Width}" columns="{Binding Height}"> </uniformgrid></itemspaneltemplate> </itemscontrol.itemspanel> <itemscontrol.itemtemplate> <datatemplate datatype="{x:Type vm:CellVM}"> <button command="{Binding Activate}" margin="10" padding="10"> <path data="M 0,1 L 1,0 L 2,1 M 1,2 L 1,0" stroke="Black" stretch="Uniform" rendertransformorigin="0.5,0.5"> <path.rendertransform> <rotatetransform angle="{Binding RotationAngle}"> </rotatetransform></path.rendertransform> </path> </button> </datatemplate> </itemscontrol.itemtemplate> </itemscontrol> <stackpanel orientation="Horizontal" grid.row="1" horizontalalignment="Center"> <label target="{Binding ElementName=ColumnChooser}">Columns: </label> <combobox name="ColumnChooser" selecteditem="{Binding Width}" itemssource="{x:Static vm:GameInfo.PossibleColumnNumber}"> <label target="{Binding ElementName=RowChooser}">Rows:</label> <combobox name="RowChooser" selecteditem="{Binding Height}" itemssource="{x:Static vm:GameInfo.PossibleRowNumber}"> </combobox></combobox></stackpanel> </grid>
</window>

Теперь нам нужно прикрепить VM к View. Лучше всего делать это не в XAML, а в App.xaml.cs (смотрите тут). Пишем:

public partial class App : Application
{ BoardVM boardVM = new BoardVM(); protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); new MainWindow() { DataContext = boardVM }.Show(); }
}

и убираем из App.xaml StartupUri.

Компилируем, запускаем. Сразу видим пустое поле. Недоработка, мы ж не сгенерировали поле в конструкторе BoardVM! Исправляем:

public BoardVM() { GenerateCells(); }

Вот что у меня получилось:

licensed under cc by-sa 3.0 with attribution.