trifle

技術メモ

Rustで2048をつくる

2048というゲームのブームはとっくに終わっているとは思いますが, 自分は未だに通学中とかライブ開演前とかの空き時間によくやっています.
そんな2048をパソコンでもやりたいなと考えたので, つくってみました.

Rustのビルドシステム兼パッケージマネージャである Cargo の環境があれば,

git clone https://github.com/7ma7X/2048.git
cd 2048
cargo run

で体験できます. Cargo を入れていない方は公式チュートリアルを参考にどうぞ(布教).
以下は感想戦のようなものです.

全体の設計

当初は一枚のプログラムでもいいかなと思いましたが, 盤面の操作が肥大化していくことに気づいたので, 盤面操作関連を board.rs に, 全体のゲーム進行を main.rs に分離しました.
board.rs にはテストケースも含まれています. なんとなく2048のようだが実は正しくない動作, になることを防ぐために, テストケースはかなり慎重に書きました. とくに盤面を動かす動作は本物の2048をやりながら慎重に色々なパターンを試しました.

2048は4*4の配列とスコアが全て

Rustにはクラスが無い代わりに, 型や構造体にトレイトを注入して機能を追加していきます. 2048の場合はこんな感じです.

pub struct Board {
  score: i32,
  board_data: [[Option<i32>; 4]; 4]
}

impl Board {
  // ここに機能を書いていく
}

盤面の数値は, 値が無い場合も含めてひとつの値として取り扱いたいので, Option 型を使います.

ゲームオーバー判定

Boardを初期化するメソッドと, 現在のBoardの状況を表示するメソッドを作った後は, まずゲームオーバー判定ができることが必要だろうと考えました.
これは「全ての盤面が数で埋まっていて, かつ縦横2連続で同じ数字が並んでいるものがない」かどうかを確認すれば良さそうです.

pub fn is_continuable(&self) -> bool {
  let mut ans = false;

  for i in 0..3 {
    for j in 0..4 {
      match self.board_data[i][j] {
        Some(x) => {
          match self.board_data[i+1][j] {
            Some(y) => if x == y { ans = true; break; },
            None => { ans = true; break; }
          }
        }
        None => { ans = true; break; }
      }

      match self.board_data[j][i] {
        Some(x) => {
          match self.board_data[j][i+1] {
            Some(y) => if x == y { ans = true; break; },
            None => { ans = true; break; }
          }
        }
        None => { ans = true; break; }         
      }
    }
  }

  ans
}

なお, テストケースはこんな感じです. Rustでは #[test] を関数の前につけることでテスト時実行の標識になり, cargo test で確認できます.

#[test]
fn check_continuability() {
  let mut test_board = Board::initialize();
  assert_eq!(test_board.is_continuable(), true);
  test_board.board_data = [
    [Some(4), Some(2), Some(4), Some(2)],
    [Some(8), Some(4), Some(8), Some(4)],
    [Some(4), Some(8), Some(4), Some(8)],
    [Some(2), Some(4), Some(2), Some(4)]
  ];
  assert_eq!(test_board.is_continuable(), false);
  test_board.board_data = [
    [Some(4), Some(2), Some(4), Some(2)],
    [Some(8), Some(4), Some(8), Some(4)],
    [Some(4), Some(8), Some(4), Some(8)],
    [Some(2), Some(4), Some(2), Some(2)]
  ];
  assert_eq!(test_board.is_continuable(), true);
  test_board.board_data = [
    [Some(4), Some(2), Some(4), Some(2)],
    [Some(8), Some(4), Some(8), Some(4)],
    [Some(4), Some(8), Some(4), Some(8)],
    [Some(2), Some(4), Some(2), None]
  ];
  assert_eq!(test_board.is_continuable(), true);
}

動かす動作

肝心の盤面を動かす動作ですが, 以下のように考えました(自分はこういうアルゴリズムを考えるのがとても苦手なので, もっと効率の良い方法があるような気がします. あくまで一例程度に).
基本的にはバブルソートならぬ「バブル数値潰し」をやります.
例えば

[16, -, 8, 8]

を左にスライドしたいとします.
1番目の要素を0番目の要素へ潰す. ところが1番目の要素は無いのでこのまま.
2番目の要素を1番目の要素へ潰す. すると, [16, 8, -, 8].
1番目の要素を0番目の要素へ潰す. 値が異なるので潰れずこのまま.
3番目の要素を2番目の要素へ潰す. すると, [16, 8, 8, -].
2番目の要素を1番目の要素へ潰す. すると, [16, 16, -, -].
1番目の要素を0番目の要素へ潰す. しかしここで [32, -, -, -] とすると実際の2048の挙動とは違うものになってしまう(つまり, 一度2倍された値をさらに2倍するようなことが禁止されている). なので適切にboolを設定して [16, 16, -, -] のままであるようにする.

基本的にはこれでOKですが, 例外があって, [a, a, b, b] のパターンは [2a, 2b, -, -] になり, 2回潰すことが必要です(本物の2048をプレイして確認してみてください). このケースだけ例外として先に弾きました.

スコア体系についても注意が必要です. 本物の2048では「新しくできた後の値」がスコアに追加されます.

実装してみたのが以下の通り. 左以外の方向についてもインデックスを変えるだけです.

pub fn move_left(&mut self) {
  for i in 0..4 {
    if self.board_data[i][0] == self.board_data[i][1] 
    && self.board_data[i][2] == self.board_data[i][3] 
    && self.board_data[i][0] != None
    && self.board_data[i][2] != None
    {
      self.board_data[i][0] = self.board_data[i][0].map(|v| v*2);
      self.board_data[i][1] = self.board_data[i][2].map(|v| v*2);
      self.board_data[i][2] = None;
      self.board_data[i][3] = None;

      self.score += self.board_data[i][0].unwrap() + self.board_data[i][1].unwrap();
    }
    else
    {
      let mut is_renewed = false;
      for j in 0..4 {
        for k in (0..j).rev() {
          match self.board_data[i][k+1] {
            None => {}
            Some(y) => {
              match self.board_data[i][k] {
                None => {
                  self.board_data[i][k] = Some(y);
                  self.board_data[i][k+1] = None;
                }
                Some(x) => {
                  if x == y && !is_renewed {
                    self.board_data[i][k] = Some(2 * y);
                    self.board_data[i][k+1] = None;
                    self.score += 2 * y;
                    is_renewed = true;
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

テストケースは以下のようなものを各方向にそれぞれ3つずつ書いています(全部実際にプレイしながらの実例).

#[test]
fn check_move_left() {
  let mut test_board = Board::initialize();
  test_board.score = 276;
  test_board.board_data = [
    [Some(16), Some(4), Some(2), Some(4)],
    [Some(2), Some(32), Some(8), Some(8)],
    [Some(8), Some(8), Some(16), Some(2)],
    [None, Some(4), Some(2), None]
  ];
  test_board.move_left();
  assert_eq!(test_board.score, 308);
  assert_eq!(test_board.board_data, [
    [Some(16), Some(4), Some(2), Some(4)], 
    [Some(2), Some(32), Some(16), None], 
    [Some(16), Some(16), Some(2), None], 
    [Some(4), Some(2), None, None]
  ]);
}

新しく発生する2または4

2048では初回に2つ, それ以降は1つ, 2または4が空いているマスに湧いていきます. 乱数を使ってうまいこと2または4を発生させます.
Rustでは乱数は外部ライブラリなので, extern crate rand; します. Rust ではライブラリ(パッケージ)のことをクレートと呼びます.
Cargo.toml の dependencies にも別途その旨が必要です. Node の package.json と同じような感じです.

本物の2048で遊んでみると, 2と4がだいたい3:1くらいの比率で出ているような気がしたので,

fn generate_two_or_four() -> i32 {
  let choices: [i32; 4] = [2, 2, 2, 4];
  let mut rng = thread_rng();

  *choices.choose(&mut rng).unwrap()
}

が新しい値発生器です.
次に, 現在の盤面から空いている座標一覧を取得する

fn fetch_no_number_area(&self) -> Vec<(usize, usize)> {
  let mut ans = Vec::new();

  for i in 0..4 {
    for j in 0..4 {
      if self.board_data[i][j] == None { ans.push((i, j)); }
    }
  }
  ans
}

を定義します.
これらを使って,

pub fn fill_in(&mut self) {
  let no_number_vec = Board::fetch_no_number_area(self);
  let mut rng = thread_rng();

  match no_number_vec.choose(&mut rng) {
    Some(tuple) => {
      let (x, y) = *tuple;
      self.board_data[x][y] = Some(Board::generate_two_or_four());
    }
    None => {}
  }
}

が定義されます.
余談ですが, 空いているマスをランダムに選択する no_number_vec.choose(&mut rng)Option 型を返すので, 最初の実装では単に .unwrap() していましたが, 実際にこの2048のプログラムで遊んでいたところ, 「全部のマスが数字で埋まっているがゲームオーバーでない状況」で panic! を起こして, そこで初めて,「空いているマスが無いので .choose が効かない」可能性に思い至り, きちんとパターンマッチさせるようにしました.
このように .unwrap() を適当に使っていると痛い目に会いますね.

それぞれの方向に動かせるかどうかを判定する

あとはこれで main.rs にゲーム本体の処理を書いて完成...といきたかったところですが, 実際にこの2048のプログラムで遊んでみると重大な問題があることが判明しました.
それは, 「本物の2048では一つも数字が潰れない方向にスライドしても2または4が湧くことはない」というのを見逃している点でした.
4方向のどの方向にも動かせないか否かを判定する .is_continuable とは別に, それぞれの方向で動かせるか否かを判定しなければならなかったのです.

そこでまず, 動かせる列というのは何者かを考えてみます.

fn is_movable_row(row: [Option<i32>; 4]) -> bool {
  match row {
    [_      , None   , None,    None   ] => false,
    [Some(a), Some(b), None,    None   ] 
      if a != b                          => false,
    [Some(a), Some(b), Some(c), None   ] 
      if a != b && b != c                => false,
    [Some(a), Some(b), Some(c), Some(d)] 
      if a != b && b != c && c != d      => false,
    _                                    => true
  }
}

こんな風にかけました. 型がある言語でよかったですね. あとはこの判定を4つの列に対して行うだけです. 左スライドであれば,

pub fn is_left_movable(&self) -> bool {
  let mut ans = false;

  for i in 0..4 {
    let row = [
      self.board_data[i][0],
      self.board_data[i][1],
      self.board_data[i][2],
      self.board_data[i][3]
    ];
    if Board::is_movable_row(row) {
      ans = true;
      break;
    }
  }
  ans
}

他の方向もインデックスを適宜変えるだけなので簡単です.
ゲーム本体の処理である main.rs の方では

  if input.starts_with("i") || input.starts_with("I") {
    if board.is_up_movable() {
      board.move_up();
      board.fill_in();
      board.display();
    }

というように .is_up_movable() の判定を噛ませることで, その方向に動かせない場合は, 操作の入力自体はできるものの, 盤面操作だけでなく表示も含めて何も起こらないようにしました.
これで, 勝手に2または4が湧かなくなったとともに, その方向に盤面が動かないこともプレイしながら分かりやすくなったので, とてもよかったです.

その他の感想

  • 全体的に Option 型を使って書けたのが安心感がありました.

  • 当初は.highscoreみたいなファイルを作ってハイスコアを保存することも考えたのですが, Rustのファイル I/O 関数の操作がかなり面倒だったので(open, read, writeなどあらゆる処理で Result 型 が帰ってきます. まあわざわざ IO 型まで用意されていてそこから抜けられないような Haskell よりはマシかも...), 諦めました.
    そもそもこの2048で遊んでも保存したくなるようなハイスコアまで全然行かないと思います. 理由として一つ挙げられるのが, 数字の背景に色が付いていないことです. 数字に色を付ける程度ならこのプログラムでも出来ますが, ケバくなりそうなのと, 個々のターミナルの環境依存性が強そうなので, そこまでやる気にはなりませんでした.

  • GitHubを漁ったらRustで書かれた2048でもっとすごいのが色々あった.

まあこれやる位なら本物かjavascriptを使ってブラウザ上で動くやつをやった方ががいいのでは