2014年11月14日金曜日

2048のGUIを実装する

今朝から.NET周りの非常に大きなニュースがたくさん出回っていて、いろいろとアツいですね。
その中でも、我々WPFerにとっても胸の熱いニュースが出ています。

The Roadmap for WPF

最近どうなってるのかよく分からなかったWPFですが、今後もまたさらなる発展が期待できそうな文面ですね。高まります。

というわけで、長らく放置にしてしまっていた2048の件ですが、やっと前回の続きのGUIの作成を行いました。もちろんWPF+Livetを使っての作成です。


前回の記事では、2048のマトリックスを表示する部分をサボってテキストボックスにし、そこにテキストとしてマトリックスとスコアを表示するプログラムにしていました。さすがにそれではみすぼらしすぎますね。というわけで、行列を表示するGUIを設計してみたいと思います。

行列状のGUIの配列と言ったらパッと思い浮かぶのはGridでしょうか。比較的簡単に綺麗に等間隔にGUIコンポーネントを配置できるので、普段からGUI設計では多用しているかと思います。

一方、今回の2048ではこのマトリックスのサイズを自由に設定できるような設計をしていました。
一般的に出回ってるゲームでは4x4ですが、別に4x4にしなければならない理由はどこにも無いので、そういったことにとらわれないコードを書いています。
しかし、Gridは通常設計段階でどのように分割するかがわかっているものです。そもそもXAMLがそこまでプログラムの条件に合わせて柔軟に動的に何かを生成するものでもないですしね。
となると、ListViewDataGridなどが思い浮かぶわけですが、いまいちピンときませんね。

というわけで、今回はGridベースにマトリックスを表示するコントロールを自作してみようと思います。
コードビハインドでいじってGridのRowDefinitionsなどをC#からいじれば動的に行の追加等はできます。が、ViewModelとViewのコードビハインドは連携しにくいので、というより、コードビハインドでViewに完結しない操作をするのは越権行為ですね。というわけで、マトリックスを表示するコントロールという形でその機能を分離し、View内完結に収めてやろうということにしたわけです。

<UserControl x:Class="Game2048.Views.GameTable"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" >
    <Grid Name="grid" />
</UserControl>

自作したコントロール、GameTableはこんな感じです。Gridを中に作って、あとはコードビハインドからいじれるように名前を定義してあげるだけです。

メインディッシュはこのXAMLのコードビハインドですね。3つの依存関係プロパティを用意してあげます。
あ、依存関係プロパティって何?って方もいるかもしれませんが、まあ、これを使うとXAMLからバインディングできるようになるって理解で良いと思います。多分。

/// <summary>
/// グリッドを表示するかどうか
/// </summary>
public bool ShowGridLines
{
    get { return (bool)this.GetValue(ShowGridLinesProperty); }
    set { this.SetValue(ShowGridLinesProperty, value); }
}
public static DependencyProperty ShowGridLinesProperty = DependencyProperty.Register("ShowGridLines", typeof(bool), typeof(GameTable), new UIPropertyMetadata((d, e) => {
    ((GameTable)d).grid.ShowGridLines = (bool)e.NewValue;
}));

/// <summary>
/// セルのテンプレート
/// </summary>
public DataTemplate ItemTemplate
{
    get { return (DataTemplate)this.GetValue(ItemTemplateProperty); }
    set { this.SetValue(ItemTemplateProperty, value); }
}
public static DependencyProperty ItemTemplateProperty = DependencyProperty.Register("ItemTemplate", typeof(DataTemplate), typeof(GameTable));

まずはこの2つの依存関係プロパティです。比較的やっていることが軽いのでまとめて紹介します。
1つ目はグリッドを表示するかどうかです。このプロパティをそのままGridに転送しています。
2つ目はセルのテンプレートです。このままいくとマトリックスのセルをそのままユーザーコントロールのコードビハインドから生成することになってしまって、XAML側からの編集性に欠けます。というわけで、テンプレートをこのプロパティを介して渡すことで、XAML側でデザインしたセルをこちらで使えるようにしています。

/// <summary>
/// 表示するマトリックス
/// </summary>
public ObservableMatrix2D<int> Matrix
{
    get { return (ObservableMatrix2D<int>)this.GetValue(MatrixProperty); }
    set { this.SetValue(MatrixProperty, value); }
}
public static DependencyProperty MatrixProperty = DependencyProperty.Register("Matrix", typeof(ObservableMatrix2D<int>), typeof(GameTable), new UIPropertyMetadata((d, e) => {
    INotifyCollectionChanged NewValue = e.NewValue as INotifyCollectionChanged;
    INotifyCollectionChanged OldValue = e.OldValue as INotifyCollectionChanged;

    GameTable me = (GameTable)d;

    if(NewValue != null)
        NewValue.CollectionChanged += me.CollectionChanged;

    if(OldValue != null)
        OldValue.CollectionChanged -= me.CollectionChanged;

    me.MatrixSizeChanged();
}));

void CollectionChanged(object o, NotifyCollectionChangedEventArgs e)
{
    if(e.Action == NotifyCollectionChangedAction.Replace)
        MatrixContentChanged();
    else
        MatrixSizeChanged();
}

/// <summary>
/// マトリックスのサイズが変更された
/// </summary>
void MatrixSizeChanged()
{
    grid.RowDefinitions.Clear();
    grid.ColumnDefinitions.Clear();
    grid.Children.Clear();
    if(Matrix != null) {
        for(int i = 0; i < Matrix.RowCount; i++)
            grid.RowDefinitions.Add(new RowDefinition() { Height = new GridLength(1, GridUnitType.Star) });
        for(int i = 0; i < Matrix.ColumnCount; i++)
            grid.ColumnDefinitions.Add(new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) });

        for(int i = 0; i < Matrix.RowCount; i++) {
            for(int j = 0; j < Matrix.ColumnCount; j++) {
                FrameworkElement fe = (FrameworkElement)ItemTemplate.LoadContent();

                fe.DataContext = Matrix[i, j];
                fe.SetValue(Grid.RowProperty, i);
                fe.SetValue(Grid.ColumnProperty, j);

                grid.Children.Add(fe);
            }
        }
    }
}

/// <summary>
/// マトリックスの中身が変更された
/// </summary>
void MatrixContentChanged()
{
    foreach(FrameworkElement child in grid.Children) {
        int r = (int)child.GetValue(Grid.RowProperty);
        int c = (int)child.GetValue(Grid.ColumnProperty);

        child.DataContext = Matrix[r, c];
    }
}

最後に、表示するマトリックスを保管する依存関係プロパティと、それ関連のメソッドです。
マトリックスはそのままint[,]型で与えたんじゃ中身の変更に対しての通知機能が一切ないので、 INotifyCollectionChangedインターフェースを実装したObservableMatrix2D<T>クラスを用意してそれを使っています。変数の変更は依存関係プロパティの登録時に与えるUIPropertyMetadataのコンストラクタの引数に与えるデリゲートで受け取れますが、これはあくまでもこの変数の変更であって、INotifyCollectionChangedが引き起こす変更通知イベントは拾わないので、これに新たにイベントハンドラを付け加えて受信できるようにしています。
マトリックスのサイズが変更されるといった大きな変更では、Gridの行と列を新たに作り直し、その後にそのGridのそれぞれのセルにDataTemplateのインスタンスを作って設定します。DataTemplateはLoadContentメソッドでそのインスタンスを作れます。インスタントを作った後は、DataContextにマトリックスのデータを埋め込み、あとはGridの添付プロパティで行と列のインデックスを指定してやっています。
マトリックスの中身が変更された時は、逆にGridの子コントロールからその添付プロパティを抽出して座標を選択し、DataContextを更新することで対応しています。

これで、汎用的なマトリックス表示コントロールができました。

なお、上で出てきたObservableMatrix2D<T>ですが、まあ、この実装は内部で2次元配列を持ってどうこうやる陳腐なものなのであえて細かく解説はしませんが、ピンポイントで説明するとしたらここでしょう。

public T this[int row, int column]
{
    get { return data[row, column]; }
    set
    {
        if(!object.Equals(data[row, column], value)) {
            T old = data[row, column];

            data[row, column] = value;
            if(CollectionChanged != null)
                CollectionChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, old));
        }
    }
}

マトリックスのある要素が変更された時はINotifyCollectionChangedで定義されたCollectionChangedイベントを発生させています。NotifyCollectionChangedEventArgsではどのような変化が起こったのかを示す列挙型を指定しますが、これは、NotifyCollectionChangedEventArgsのコンストラクタによって指定できる列挙型のメンバが決まっているので、MSDNをよく読んで作ってください。

さて、実際にこの作ったコントロールを使っていきます。

<v:GameTable Matrix="{Binding Matrix}" ShowGridLines="False" >
    <v:GameTable.ItemTemplate>
        <DataTemplate>
            <Grid>
                <Rectangle Stroke="Black" StrokeThickness="1" Fill="{Binding Converter={StaticResource NumToBrushConv}}" RadiusX="10" RadiusY="10" />
                <TextBlock Text="{Binding Converter={StaticResource ZeroDontSshowConv}}" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="30" />
            </Grid>
        </DataTemplate>
    </v:GameTable.ItemTemplate>
</v:GameTable>

このようにMainWindow.xamlで定義します。Matrixの中身はViewModelのデータをバインディングします。今回は、セルのテンプレートでグリッド枠相当の描写をやるので、ShowGridLinesはFalseにしています。

RectangleとTextBlockでは、それぞれFillとTextのデータにそのMatrixの各要素の値がバインディングされています。
TextBlockの場合は、そのままバインディングするとゼロもそのまま表示されてしまうので、ゼロの場合は表示しないようにするようなコンバーターを実装してそれを読ませています。
Rectangleの場合は、数字の大きさによって色を変化させるようなコンバーターを実装して、それをかませることで数値を色に変換しています。

さて、これを周囲の上下左右のボタンで操作できるようにしています。

<Button DockPanel.Dock="Left" Content="←" IsEnabled="{Binding CanMoveLeft}" >
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="MoveLeft" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>

これは左の実装ですが、上下左右4種類実装してあげています。
また、キーボードの矢印キーを使っても同様の操作ができるべきなので、それも実装してあげます。

<ei:KeyTrigger Key="Left">
    <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="MoveLeft" />
</ei:KeyTrigger>

Livetでは、このようにKeyTriggerをi:Interaction.Triggersの中に入れてあげれば、キー入力から直接メソッドをバインディングして呼び出せるので便利です。

これで完成です。


どうです?それっぽくなりましたよね?
ですがまあまだスコアの表示の実装とか、設定、リセット等の実装ができていません。
その辺りが煮詰まってきたら、オートソルバーの実装かな?

どれだけ時間のかかることやら。