2014年9月11日木曜日

2048のロジックを実装する

私はブームを2周くらい遅れて追いかける性質があるようで、ここ最近、2048というゲームにはまっています。ググればたくさん出てきますし、オープンソースってだけあって亜種も結構あるようなのでまあリンクは張らずにおいておきます。

さて、このようにシンプルなゲーム、ある程度やり始めると「これ、自動でやらせればよくね?」って思うようになってきますよね。えっ、ならないだって?:;(∩´﹏`∩);:なってください~。昔、マインスイーパーを自動で解くプログラムを書いたことがありましたが、今回は2048を解いていきたいと思います。

といってもスマホアプリは完全に守備範囲外ですし、別にゲームをやりたいんじゃなければスマホアプリにする必要もありません。
というわけで、お得意のC#で作ろうと思いましたが、オープンソースの2048はJavascriptで作られているようです。というわけで、2048をC#で再実装してしまいました。


まずは、オートソルバーを想定してインターフェースを実装してみましょう。
要するに、2048で必要な操作や、ゲームに際して提供する必要がある操作をインターフェースで提供してやります。そうすることで、別の人が書いた2048のアプリケーションでもそのインターフェースさえ実装していればオートソルバーが使えるようになります。

public interface I2048
{
    /// <summary>
    /// 行の本数
    /// </summary>
    int RowCount { get; }

    /// <summary>
    /// 列の本数
    /// </summary>
    int ColumnCount { get; }

    /// <summary>
    /// スコア
    /// </summary>
    uint Score { get; }

    /// <summary>
    /// ゲームオーバーかどうか
    /// </summary>
    bool IsGameOver { get; }

    /// <summary>
    /// row, columnの値
    /// </summary>
    /// <param name="row">行</param>
    /// <param name="column">列</param>
    /// <returns>その値</returns>
    uint this[uint row, uint column] { get; }

    /// <summary>
    /// 指定した行を取得するメソッド
    /// </summary>
    /// <param name="row">行番号</param>
    /// <returns>行の中身</returns>
    IEnumerable<uint> GetRow(int row);

    /// <summary>
    /// 指定した列を取得するメソッド
    /// </summary>
    /// <param name="column">列番号</param>
    /// <returns>列の中身</returns>
    IEnumerable<uint> GetColumn(int column);


    /// <summary>
    /// 左に動かす
    /// </summary>
    /// <returns>動かせたかどうか</returns>
    bool MoveLeft();

    /// <summary>
    /// 左に動かせるかどうか
    /// </summary>
    bool CanMoveLeft { get; }

    /// <summary>
    /// 右に動かす
    /// </summary>
    /// <returns>動かせたかどうか</returns>
    bool MoveRight();

    /// <summary>
    /// 右に動かせるかどうか
    /// </summary>
    bool CanMoveRight { get; }

    /// <summary>
    /// 上に動かす
    /// </summary>
    /// <returns>動かせたかどうか</returns>
    bool MoveUp();

    /// <summary>
    /// 上に動かせるかどうか
    /// </summary>
    bool CanMoveUp { get; }

    /// <summary>
    /// 下に動かす
    /// </summary>
    /// <returns>動かせたかどうか</returns>
    bool MoveDown();

    /// <summary>
    /// 下に動かせるかどうか
    /// </summary>
    bool CanMoveDown { get; }
}

これくらいの機能さえあれば2048のゲームの状態を取得することも操作することもできますかね。
2048の行数、列数はともに4が一般的ですが、別に4じゃないとこのゲームができないわけではないので(ゲームバランスの問題はあるでしょうけども)任意の列・行に対応できるような仕様にしています。
インデクサーで値が取得できればGetRowとかGetColumnは必須ではないですが、このゲームは行単位や列単位でものを見ることが多いので実装を義務付けても罰は当たらないでしょう。

さて、このインターフェースを実装していきます。
C言語系の言語は変数名等の最初が数字にできないので「2048」とかいうクラスを作るわけにはいきません。なので、Boardっていうクラスにしてみました。

コンストラクタはゲームの初期化を行っています。

public Board(int row, int column)
{
    if(row < 2 || column < 2)
        throw new ArgumentOutOfRangeException("The row and the column count must be bigger than or equals 2");

    RowCount = row;
    ColumnCount = column;

    table = new uint[row, column];

    PossibilityOfTwo = 0.75;

    MakeNumberAppear();
    MakeNumberAppear();
}


1マスだけではゲームができないので、2x2以上を義務付けています。正方形である必要はなく、例えば5x3などの長方形でもOKです。
PossibilityOfTwoは新しい数字が湧き出してくるときに2が出る確率です。残りは4が出てきます。とりあえず確率75%にしてみました。
MakeNumberAppearは新しい数字を湧き出させるメソッドです。ゲームスタート時点では2ヶ所に数字を表示しますから、2回呼び出しています。

ここでMakeNumberAppearの中身を見てみましょう。

/// <summary>
/// 新しい数字を沸き上がらせるメソッド
/// </summary>
private void MakeNumberAppear()
{
    var zero = Enumerable.Range(0, RowCount).
        SelectMany(row => GetRow(row).Select((val, col) => new { Row = row, Col = col, Val = val })).
        Where(p => p.Val == 0).
        ToArray();

    if(zero.Length == 0)
        throw new InvalidOperationException("There are no free space.");

    uint cellNumber = (uint)(random.Next() % zero.Length);

    table[zero[cellNumber].Row, zero[cellNumber].Col] = (random.NextDouble() < PossibilityOfTwo) ? 2u : 4u;
}

まずは、ゼロのセルを列挙しています。
とりあえず全てのセルを列挙し、匿名クラスでその行番号、列番号とそのセルの値を表すクラスを作り、最後にWhereでセルの値がゼロのもののみを拾ってきています。
そして、そして乱数でそのゼロのセルのなかから1つを選び、さらにそこから指定した確率で2か4を入れています。


指定した行をまるごと引っ張ってくるメソッドGetRowですが、実はとても簡単に実装できます。

/// <summary>
/// 指定した行を取得するメソッド
/// </summary>
/// <param name="row">行番号</param>
/// <returns>行の中身</returns>
public IEnumerable<uint> GetRow(int row)
{
    for(int i = 0; i < ColumnCount; i++)
        yield return table[row, i];
}

IEnumerableの概念を最初に勉強した時は「使う分には便利だけど実装はクッソめんどくさいだろうなー」って思いましたが、yield文によって上記のように超簡単に実装できるんですよね。なんていうか、for文から抜けてないかのような表記なのに抜けたような挙動の構文ってことでフローがわかりにくいようにも思えますが、この構文のシンプルさは私は大好きですね。

最後に、上下左右に動かすプログラムを実装しましょう。これは案外複雑です。
  1. 動かした方向に数が入ってるすべてのセルを詰める
  2. 動かした先のほうから反対方向へ順に見て行って、その反対方向側の隣のセルに同じ値があった場合、合体する。合体した場合は、合体した次のセルから隣のセルを見ていき、合体しなかった場合は次のセルを見ていく。
  3. ゲームスコアは、合体した後の値が加算される。
1はそこまで難しくなく実装できそうです。しかし、2が面倒くさいですね。普通に「隣のセルと同じだったら合体する」みたいなロジックで組んでしまうと、例えば4 2 2って並んでいた時に右に動かしたら、1回目で2 2が合体して4 4になり、さらにここで並んでしまうのでこれが合体して8になってしまいます。そのような現象を回避するために、動かした先のほうから反対方向へ順番に見ていく必要があります。結構複雑な感じになりそうです。

あ、ここで2の動作を「縮退」と名付けました。なんか2つのセルが合体して1つのセルに縮まる感じです。量子力学とかサーバー運用等に関して言う縮退の意味を考えるとどうなのかなーって気もしなくもないですが、なんていうか、ほかに良い日本語が見つからなかったので。

さて、ここで左に動かすメソッドを見ていきます。

/// <summary>
/// 左に動かす
/// </summary>
/// <returns>動かせたかどうか</returns>
public bool MoveLeft()
{
    if(CanMoveLeft) {
        for(int i = 0; i < RowCount; i++) {
            uint score;

            SetRow(i, GetRow(i).Where(p => p > 0).Degenerate(out score).AddZero(ColumnCount));

            Score += score;
        }
        MakeNumberAppear();
    }
    return CanMoveLeft;
}

あらかじめ動かせるかを調べてから、動かせた場合に各行について処理をしています。行を取得し、ゼロのセル、すなわち何も数字が無いセルをWhereで省きます。その後、縮退のDegenerateメソッドを呼び、最後に列数になるまでゼロのセルを後ろに付けたします。これだけです。DegenerateとAddZeroは私が実装した拡張メソッドです。中身は後述します。

このDegenerateメソッドのアルゴリズム、動かせなかったら動かさないまま値を返すので一見あらかじめ動かせるかどうかを調べてから動かす必要はなさそうです。ではなぜこれが必要かというと、2か4を湧き上がらせる条件として「動かした」ということが必要だからです。動かせる時のみ動かし、新しい数字を湧き上がらせるという手順を踏んでいます。

さて、上記の例は左へ動かす場合ですが、同様に上へ動かす場合はGetRow(i)の代わりにGetColumn(i)を呼び出してあげれば問題ないことは容易く想像がつくでしょう。もちろん、AddZeroの引数とかには注意してあげる必要がありますが。
じゃあ、右や下へ動かす場合はどうするか。
簡単です。Whereの後とAddZeroの後にReverse()を入れてあげるだけです。

GetRow(i).Where(p => p > 0).Reverse().Degenerate(out score).AddZero(ColumnCount).Reverse();

行の中身の順番を入れ替えて縮退させれば現象は左右逆になりますよね。下へ動かす場合も同様です。これでだいぶシンプルに各方向へ動かすメソッドが実装できました。

ここで、Degenerate()とAddZero()の中身を見てみましょう。まずはDegenerateから。

/// <summary>
/// 行または列をくっつけて成長させるメソッド
/// </summary>
/// <param name="array">成長前の配列</param>
/// <param name="score">点数</param>
/// <returns>成長後の配列</returns>
public static IEnumerable<uint> Degenerate(this IEnumerable<uint> array, out uint score)
{
    score = 0;
    if(array.Count() <= 1)    //要素が1個以下ならそのまま
        return array;
    else if(array.First() == array.Skip(1).First()) {    //次と同じだったら足して、残りをDegenerateする
        uint add = array.First();
        IEnumerable<uint> ret = array.Take(1).Select(p => p * 2).Concat(array.Skip(2).Degenerate(out score));
        score += add;
        return ret;
    } else    //次と同じじゃなかったら、その次のからDegenerateする
        return array.Take(1).Concat(array.Skip(1).Degenerate(out score));
}

3パターンにわかれます。
まず、要素が1個以下ならば縮退は起こりえないのでそのまま返します。
次に、最初の要素と2つ目の要素が同じだったら1つ目の要素をarray.Take(1).Select(p => p * 2)で2倍にしています。さらに、この配列を2つスキップした先の要素をDegenerateで再帰呼び出しします。そして、2倍にした側にConcatで連結しています。このとき、点数計算も同時にしています。
最後のパターンは1つ目と2つ目が同じじゃなかった時、2つ目の要素以降を再帰呼び出しで縮退させています。
これで全ての動作が網羅できますよね。我ながらなかなかシンプルにまとめあげたと思っています

つづいてAddZeroです。

/// <summary>
/// 指定した個数の要素数になるまで末尾にゼロをつなげるメソッド
/// </summary>
/// <typeparam name="T">型</typeparam>
/// <param name="array">元の配列</param>
/// <param name="TotalLength">最終的な長さ</param>
/// <returns>ゼロをつなげた配列</returns>
public static IEnumerable<T> AddZero<T>(this IEnumerable<T> array, int TotalLength)
{
    int count = array.Count();

    if(count == TotalLength)
        return array;
    else if(TotalLength < count)
        throw new InvalidOperationException("TotalLength must be bigger than or equals to array length");
    else
        return array.Concat(Enumerable.Range(1, TotalLength - count).Select(p => default(T)));
}

指定した個数より元の配列のほうが長かったら例外を吐くようにしています。
Enumerable.Rangeで追加分の長さの連続した数列を作りますが、これは必要ないのでSelectで使わないで捨てています。ジェネリックメソッドにしているので、default(T)になりますね。


最後に、動かせるかどうかを示すプロパティについて見てみます。
とはいっても、動かした後のデータと動かす前のデータをSequenceEqualで比べてるだけですがね。

/// <summary>
/// 左に動かせるかどうか
/// </summary>
public bool CanMoveLeft
{
    get
    {
        for(int i = 0; i < RowCount; i++) {
            if(!GetRow(i).Where(p => p > 0).Degenerate().AddZero(ColumnCount).SequenceEqual(GetRow(i)))
                return true;
        }
        return false;
    }
}

先述した通り、このプロパティで動かせるかを判断した後、改めて実際に動かす動作をしています。2度手間であまり賢い実装じゃないかもしれませんが、まあ、まとまりはいいのでこれでいいでしょう。

これに付随して、ゲームオーバーを表すプロパティ「IsGameOver」も実装しました。ゲームオーバーしたかどうかはすなわち全方向に動かせないということですので、そうなるように実装しました。

/// <summary>
/// ゲームオーバーかどうか
/// </summary>
public bool IsGameOver
{
    get
    {
        return !CanMoveLeft && !CanMoveRight && !CanMoveUp && !CanMoveDown;
    }
}


さて、ここまで見てもらってわかると思いますが、C#でデータの集まりを操作しようとするとガチガチのLINQ実装になりました。LINQは遅延評価なので、何度も同じIEnumerable<T>を評価するような式は実効効率の意味ではあまり賢くないかもしれませんが(1度配列などに落としておくと早くなると見込める)、まあ、今の段階では特に考えなくていいでしょう。全部LINQで書いてしまったほうが綺麗ですし。


さて、これでだいたいのロジックが出そろいました。
今回のテーマが「ロジックの実装」で、最終目標が「2048のオートソルバー」なのでUIは今のところ全然本気出して実装していませんが、とりあえず動作を確認するために簡単に実装しました。
こんな感じになっています。


次回はUIの整理かな~?

0 件のコメント:

コメントを投稿