Игра на 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.