22
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社ラグザイアAdvent Calendar 2023

Day 18

WSL2上にRuby開発環境を構築してテトリスを作ってみた

Last updated at Posted at 2023-12-17

この記事は株式会社ラグザイア Advend Calendar 2023 の記事です。

Ruby初学者が学習のために、WSL2上にRuby開発環境を構築してテトリスを作ってみました。
本記事は、その道のりをチュートリアル形式でまとめたものです。

もくじ

開発環境構築
Rubyのお勉強
テトリス開発
あとがき
参考記事

開発環境構築

今回の開発環境構築の対象となるPCのOSはWindows11です。
Windows版のRubyをインストールするという選択肢もありますが、今回はWSL2でLinux環境を作り、そこにLinux版のRubyをインストールする方法を選びました。
そんなわけで、本記事の開発環境構築はWSL2をインストールするところから始まります。
WSL2って何?って方は以下の解説が参考になると思います。

WSL2のインストール

コマンドプロンプトを開き、以下のコマンドを実行します。

コマンドプロンプト
wsl --install

WSL2のインストールが始まります。何か聞かれたら内容を確認した上で全部「はい」します。
正常に終了したらWindowsを再起動する指示が出るので再起動します。
再起動後、自動的にターミナルが立ち上がり、Ubuntu(WSL2の既定のLinuxディストリビューション)が起動します。
Ubuntu上でusernamepasswordの登録を要求されるので入力します。(ここで何を入力したか忘れない様に!)
なお、passwordは入力しても画面には表示されませんが、内部的には入力されています。
登録後、しばらく待って以下の様なコマンドライン入力待ち状態になればOKです。(緑色の文字列部分は[ユーザー名]@[PC名]になります。)
image.png
この時点で既にWindowsのスタートメニューにUbuntuが追加されているはずなので、今後はそれを実行することでこの状態に戻ってくることが出来ます。
また、エクスプローラーのナビゲーションウィンドウに追加されているペンギンアイコン(Linux)からUbuntuのファイルシステムを操作することが出来ます。
image.png
エクスプローラーから見る場合のホームディレクトリ(Ubuntu起動時の初期ディレクトリ)の位置は以下になります。
\\wsl.localhost\Ubuntu\home\<ユーザー名>

これでWindows上にLinux環境(Ubuntu環境)を作ることが出来ました。

Rubyのインストール

WSL2上のUbuntuにRubyをインストールします。
Ubuntu標準のパッケージ管理システムのaptapt-getでもRubyをインストールすることが出来ますが、それだと少し古いバージョンがインストールされてしまいます。
折角なら出来るだけ新しいバージョンのRubyを使いたい & 複数のバージョンのRubyを切り替えられる方が便利なので、今回は少々導入時の手間が増えてしまいますが、rbenvというRubyのバージョン管理ツール経由でインストールを行います。

Ubuntuで以下のコマンドを上から1行ずつ順番に実行し、rbenvを使える様にします。
複雑そうに見えるかも知れませんが、順番に実行するだけで大丈夫です。
もし途中でパスワードの入力を求められたら先ほど登録したパスワードを入力してください。

Ubuntu
# パッケージリストを更新する
sudo apt-get update
# gitをインストールする
sudo apt-get install git
# rbenvをGitHubリポジトリからクローンする
git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv
# シェルにrbenvのパスを通す
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
source ~/.bashrc
# Rubyのビルドに必要なパッケージをインストールする
sudo apt-get install -y gcc make libyaml-dev libssl-dev zlib1g-dev
# ruby-buildプラグインをクローンする(Rubyをビルドするためのrbenvプラグイン)
git clone --depth 1 https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build

これでrbenvが使える様になりました。
以下のコマンドを実行すると、rbenvでインストール可能なRubyのバージョン一覧が表示されます。

Ubuntu
rbenv install -l

今回、筆者は以下のコマンドで3.1.4と3.2.2の二つのバージョンのRubyをインストールしました。
複数バージョンの導入が不要の場合は、最新安定版だけのインストールで問題ありません。
(筆者の場合、受験予定だったRuby試験で対象となるバージョンが3.1.4だったので、本記事公開時点の最新安定版である3.2.2と併せてインストールしました。)
インストールには数分程の時間が掛かる場合があるのでじっくり待ちましょう。

Ubuntu
rbenv install 3.1.4
rbenv install 3.2.2

以下のコマンドで、rbenv経由でインストールしたRubyのバージョン一覧を確認できます。

Ubuntu
rbenv versions

以下のコマンドで、rubyコマンドで使われるRubyのバージョンの指定ができます。

Ubuntu
rbenv global 3.1.4
rbenv global 3.2.2

以下のコマンドで、rubyコマンドで使われるRubyのバージョンを確認できます。

Ubuntu
ruby -v

Rubyのバージョンをコマンドひとつで切り替えられる様になりました。
image.png

これでRubyのインストールができました。
この先はバージョン3.2.2のRubyを使って進めます。

Hello world!

インストールしたRubyを使ってプログラムが実行出来ることを確認します。
作業用のディレクトリを作成し、その中にrbファイル(Rubyのコードを書くファイル)を作成します。

Ubuntu
mkdir ~/hello
touch ~/hello/world.rb

Vimnano等のテキストエディタを使って、作成したworld.rbに以下のコードを書き込みます。
または、エクスプローラーからファイルを参照する事で、Windowsにインストールされているお好みのエディタを使ってファイルを編集する事も可能です。(Linux環境では改行コードをLFで統一する様に注意してください。)

~/hello/world.rb
puts 'Hello world!'

実行します。

Ubuntu
ruby ~/hello/world.rb
実行結果
Hello world!

これでRuby開発環境の構築が出来ました。

Rubyのお勉強

Rubyの開発環境が作れても、Rubyプログラムの書き方を知らなければ何も作れません。
筆者は以下の書籍を読んで勉強しました。

↓ プログラミングそのものが初心者の方にオススメの本です。

↓ Ruby以外のプログラミング言語での開発経験がある方にはこの本がオススメです。

↓ 初学者に優しい内容ではありませんが、Rubyの資格取得を目指すなら必須級の本です。

Web上にもRuby入門用のサイトやサービスがたくさんあります。
自分に合いそうな勉強方法を選ぶのがよいと思います。

テトリス開発

筆者は新しくプログラミング言語を学習する際はとりあえずテトリスを作る様にしています。
理由は楽しいからです。

完成したらこんな感じ

完成後のソースコード(GitHubリポジトリ)

完成後のイメージ
image.png

完成後の動画

開発を始める前にcursesをインストールする

テトリスというゲームはリアルタイムに画面が更新されないといけません。
かといって、2Dゲーム用のgem(Rubyにおける外部ライブラリ)を使用するというのは初学者である筆者にはハードルが高いと感じました。
そこで、比較的簡単に画面の更新が実現出来るcursesというgemを使用します。
cursesを使うと、VimやRogueの様なテキストベースのユーザーインターフェースを作成できます。
テトリスの様な見た目がシンプルでも大丈夫なゲームなら、これを使って作ることが出来ます。
以下のコマンドでcurses gemをインストールします。

Ubuntu
# curses gemのビルドに必要なパッケージをインストールする
sudo apt-get install -y libncurses5-dev libncursesw5-dev
# デフォルトでインストールされているgemを更新する
gem update
# curses gemをインストールする
gem install curses

cursesを試しに使ってみる

先ほどのHello world!プログラムをcursesを使う形に書き直してみます。

~/hello/world.rb
require 'curses'

Curses.init_screen # cursesによる画面制御の開始
Curses.erase # 画面をクリア
Curses.setpos(3, 10) # 画面の4行目、11文字目にカーソルを設定(0から数える)
Curses.addstr("Hello world!") # カーソルを設定した位置に文字列を表示
Curses.refresh # 画面に変更を反映
Curses.getch # キー入力を待つ。入力があるまでプログラムはここで停止
Curses.close_screen # cursesによる画面制御を終了

実行します。

Ubuntu
ruby ~/hello/world.rb

4行目の左から11文字目にHello world!とだけ表示されることを確認できます。
何かキーを押すと元の画面に戻ります。

実行結果



          Hello world!



画面が更新されるプロセスについて

  1. init_screen(初期化)
  2. erase(画面クリア)
  3. setpos(カーソル位置指定)
  4. addstr(文字列書き込み)
  5. refresh(画面を反映)
  6. 画面を再描画する場合、2から繰り返す(画面の一部のみ書き換えの場合は3から)

上記のプロセスが理解できればとりあえず画面描画処理を書くことが出来ます。
終了時はclose_screenを呼んで閉じることを忘れない様にしてください。

本記事ではcursesの詳細な使い方の説明は行いません。
もし興味があれば、本記事の末尾に参考記事へのリンクがあるので、そちらを読んでみてください。

クラス設計

では、テトリスを作り始めます。

行き当たりばったりで作り始めるのも冒険感があって楽しいですが、「こんな感じに作ろ~」っていうのをあらかじめ考えてから作り始める方がゴールが見え易くなり、挫折率をある程度下げられます。
この「あらかじめ考える」ことを設計といいます。
Rubyはオブジェクト指向型言語なので、先にクラス設計すると作り易くなります。
ざっくりとどんな役割やモノが登場して、それらがどう関係するかを考えます。

テトリスを構成するクラス達を考えた結果、こんな感じになりました。(雑ですがクラス図のつもり)
クラス図.png

  • Game
    • ゲーム全体を統括するクラス
  • PlayerInput
    • プレイヤーからの入力を取得するクラス
  • Field
    • テトリスの盤面を表すクラス
  • Cell
    • Fieldを構成する要素クラス
  • Display
    • 画面を描画するクラス
  • Tetrimino
    • テトリミノ(プレイヤーが操作する上から降ってくるブロック)の基底クラス
  • TetriminoType*
    • 7種類のテトリミノそれぞれを表すクラス(Tetriminoを継承する派生クラス)

これらのクラスをひとつずつコーディングしていくことでテトリスが出来上がります。

開発準備

テトリスを作るための作業用ディレクトリを作ります。
以下のコマンドでtetrisというディレクトリを作成します。

Ubuntu
mkdir ~/tetris

ここに実行用rbファイルとして、tetris.rbを作成します。(touchコマンドでのファイル作成は以後省略します。)
コードの中でGameというまだ未定義のクラスを使っていますが、この後で作ります。

~/tetris/tetris.rb
require_relative 'lib/Game'

game = Game.new
game.run

tetrisディレクトリの中に、更にlibディレクトリを作ります。
ゲームの本体部分はこの中に作成していきます。

Ubuntu
mkdir ~/tetris/lib

libディレクトリの中に、Gameクラスを定義するrbファイルを作成します。
Gameクラスはゲーム全体を統括するクラスになります。
runメソッドの中身は、今はとりあえず空っぽで。

~/tetris/lib/Game.rb
# ゲーム全体の統括
class Game
  def run
  end
end

今後、以下のコマンドでプログラムを実行出来ます。
この段階では実行しても何も起こりません。

Ubuntu
ruby ~/tetris/tetris.rb

プレイヤーの入力処理

テトリスはゲームなので、プレイヤーが入力操作を行える様になっていないといけません。
プレイヤーからの入力を取得するPlayerInputクラスを作成します。
cursesは画面表示だけでは無くキーボードからの入力処理もサポートしているので、それを使って作ります。

PlayerInputクラスの仕様

  • 初期化時
    • リアルタイムなキー入力取得のためにcursesを使う準備をする
  • どのキーが入力されているかを取得するメソッドを持つ
    • プレイヤーが任意にゲームを終了出来る様に、エスケープキーの入力を取得出来るようにする
    • 方向の入力は、いわゆるWASD方式にする
    • テトリミノを回転する操作があるので、それぞれJを左回転,Kを右回転に割り当てる
~/tetris/lib/PlayerInput.rb
require 'curses'

# プレイヤーからの入力を取得する
class PlayerInput

  # エスケープキーが押下された場合にCurses.getchが返す値
  ESCAPE_KEY = 27

  def initialize
    # キー入力をエコー表示しない
    Curses.noecho
    # getchを非ブロッキングモードにする
    Curses.stdscr.nodelay = 1
    # カーソルを非表示にする
    Curses.curs_set(0)
  end

  # 入力されたキーに対応したシンボルを返す
  def from_key
    case Curses.getch
    when ESCAPE_KEY
      :exit
    when 'a'
      :left
    when 'd'
      :right
    when 'w'
      :up
    when 's'
      :down
    when 'j'
      :rotate_left
    when 'k'
      :rotate_right
    else   
      :none
    end
  end
end

Gameクラスのrunメソッドに、エスケープキーが押されたら抜ける無限ループを追加します。
無限ループには、1秒間にだいたい60回のループになる様に16ミリ秒のウェイトも入れます。
以後、便宜上このループの事をフレームと呼びます。(ゲーム用語です。)

この段階でのGameクラスの仕様

  • 初期化
    • PlayerInputクラスを使えるようにする
  • 無限ループ(フレーム)を回すメソッドを持つ
    • フレーム毎に以下の事を行う様にする
      • キーボードからの入力を取得する
      • ESCキーが押されていたらゲームを抜ける
      • 16ms待つ(だいたい60FPSにするため)
~/tetris/lib/Game.rb
+ require_relative 'PlayerInput'

# ゲーム全体の統括
class Game
+ def initialize
+   @player_input = PlayerInput.new
+ end

  def run
+   #フレームのループ
+   loop do
+     # キーボードからの入力を取得
+     input = @player_input.from_key
+
+     # ESCキーを押されたらループを抜ける
+     if input == :exit
+       break
+     end
+
+     # だいたい60FPSにするために16ms止める
+     sleep(0.016)
+   end
  end
end

この時点で実行するとcursesが作る真っ暗な画面が立ち上がります。
エスケープキーを押すと真っ暗な画面を抜けてコマンド入力画面に戻る事が確認できます。

フィールドの定義

テトリスのフィールド(盤面)を定義します。
テトリスのフィールドは、縦20個 * 横10個のセルを持つグリッドで表現できます。
フィールドの壁になる部分もグリッドの一部(壁ブロック)として扱える方が後で便利なので、それらを含めて縦22個 * 横12個のセルを持ったグリッドを用意します。
1.png

まずは、グリッドを構成するセル単体を表現するCellクラスを作成します。

Cellクラスの仕様

  • 状態
    • 座標情報
      • 横方向(x軸)何番目のセルか
      • 縦方向(y軸)何番目のセルか
    • セルは壁ブロックか
    • セルにブロックが存在するか
    • ブロックが何色か
  • セルにブロックをセットするメソッドを持つ
  • セルのブロックを消去するメソッドを持つ
~/tetris/lib/Cell.rb
# テトリスのフィールドを構成するセル
class Cell
  attr_reader :pos_x, :pos_y, :is_wall, :has_block, :color

  def initialize(x, y, is_wall)
    @pos_x = x
    @pos_y = y
    @is_wall = is_wall
    @has_block = is_wall

    if is_wall 
      @color = :brown
    else
      @color = :none
    end
  end

  # ブロックをセットする
  def set_block(color)
    if !@is_wall
      @has_block = true
      @color = color
    end
  end

  # ブロックを消去する
  def remove_block
    if !@is_wall
      @has_block = false
      @color = :none
    end
  end
end

フィールドを表現するFieldクラスを作成します。

Fieldクラスの仕様

  • グリッド配列をインスタンス変数に持つ
    • 初期化時、各セルのインスタンスを生成してグリッド配列に追加することでフィールドを表現する
    • 最も外側になるセルは壁ブロックにするため、初期化時にis_wall引数がtrueになるようにする
  • 指定した座標のセルを取得するメソッドを持つ
~/tetris/lib/Field.rb
require_relative 'Cell'

# テトリスのフィールド
class Field
  attr_reader :grid

  # グリッド全体の幅と高さ
  MINIMUM_GRID_WIDTH = 0
  MAX_GRID_WIDTH = 11
  GRID_WIDTH_RANGE = (MINIMUM_GRID_WIDTH..MAX_GRID_WIDTH)
  MINIMUM_GRID_HEIGHT = 0
  MAX_GRID_HEIGHT = 21
  GRID_HEIGHT_RANGE = (MINIMUM_GRID_HEIGHT..MAX_GRID_HEIGHT)

  # グリッド内側の幅と高さ
  MINIMUM_GRID_INSIDE_WIDTH = 1
  MAX_GRID_INSIDE_WIDTH = 10
  GRID_INSIDE_WIDTH_RANGE = (MINIMUM_GRID_INSIDE_WIDTH..MAX_GRID_INSIDE_WIDTH)
  MINIMUM_GRID_INSIDE_HEIGHT = 1
  MAX_GRID_INSIDE_HEIGHT = 20
  GRID_INSIDE_HEIGHT_RANGE = (MINIMUM_GRID_INSIDE_HEIGHT..MAX_GRID_INSIDE_HEIGHT)

  def initialize
    # フィールドを構成するグリッドを作成する
    @grid = Array.new
    GRID_WIDTH_RANGE.each do |x|
      GRID_HEIGHT_RANGE.each do |y|
        # グリッドの端は壁にする
        is_wall =
          x == MINIMUM_GRID_WIDTH ||
          y == MINIMUM_GRID_HEIGHT ||
          x == MAX_GRID_WIDTH ||
          y == MAX_GRID_HEIGHT
        # グリッドにセルを追加する
        @grid.push(Cell.new(x, y, is_wall))
      end
    end
  end

  # 指定された座標のセルを返す
  # 指定された座標のセルが無い場合はnilを返す
  def cell_at(x, y)
    @grid.each do |cell|
      if cell.pos_x == x && cell.pos_y == y
        return cell
      end
    end
    nil
  end
end

テトリミノ定義

プレイヤーが操作するテトリミノを定義します。
テトリミノは全部で7種類あります。
まずは基底クラスとなるTetriminoクラスを作成します。7種類のテトリミノが共通して持つ要素を定義します。
プレイヤーが操作する対象ということもあって、最もボリュームのあるクラスとなります。

Tetriminoクラスの仕様

  • 初期化時
    • Fieldに依存するクラスのため、Fieldのインスタンスを受け取ってインスタンス変数に格納する
    • 初期開始位置を設定する
    • 何フレーム毎に落下(1セル分下に移動)するかを設定する
  • 状態
    • 現在のフィールド上での位置(x軸, y軸)
    • テトリミノの色
    • 回転状態
    • 接地しているか
    • 落下カウント
  • メソッド
    • 衝突判定(テトリミノがフィールドのブロックと重なっているか)
    • ブロックの形状を取得するメソッド(派生クラスでオーバーライドされる)
    • フレーム毎の更新処理
      • 左右移動処理
        • プレイヤーからの左右方向の入力を検知したら呼ばれる
        • 現在の位置のx軸を加算・減算する
        • 移動した先で衝突を検知したらすぐに元の位置に戻すことでフィールドのブロックにめり込まない様にする
      • 回転処理
        • プレイヤーからの回転の入力を検知したら呼ばれる
        • 回転状態を加算・減算する
        • 回転状態によるテトリミノの形状は、継承先の各テトリミノのクラスで定義する
        • 回転した後に衝突を検知したらすぐに元の回転状態に戻すことでフィールドのブロックにめり込まない様にする
      • 落下処理
        • 落下とは1セル分下に移動すること
        • フレーム毎に落下カウントを減算し、0になったら落下する
        • プレイヤーからの下方向の入力を検知したら落下カウントに関わらず落下する
        • 落下したら落下カウントをデフォルト値に戻す
        • 落下後、衝突を検知したらすぐに元の位置に戻して着地処理を実行する
        • 着地処理
          • フィールド上の着地時のテトリミノの位置にブロックを配置する
          • 着地フラグを立てる
~/tetris/lib/Tetrimino.rb
require_relative 'Field'

# テトリミノの基底クラス
class Tetrimino
  attr_reader :pos_y, :pos_x, :color, :landed

  START_POS_X = 4
  START_POS_Y = 0
  FALL_COUNT_INITIAL_VALUE = 50

  def initialize(field)
    @field = field
    @pos_x = START_POS_X
    @pos_y = START_POS_Y
    @color = :none
    @landed = false
    @rotate = 0
    @fall_count = FALL_COUNT_INITIAL_VALUE
  end

  # フレーム毎の更新
  def update(input)
    # 移動操作
    movement(input)
    # 落下処理
    fall(input)
  end

  # ブロック取得メソッド(派生クラスでオーバーライドされる)
  def blocks
    blocks = Array.new(4)
    blocks[0] = [0, 0, 0, 0]
    blocks[1] = [0, 0, 0, 0]
    blocks[2] = [0, 0, 0, 0]
    blocks[3] = [0, 0, 0, 0]
    blocks
  end

  private

  # テトリミノがフィールドのブロックに重なっているか
  def collision?
    blocks.each_with_index do |sub_array, index_y|
      sub_array.each_with_index do |element, index_x|
        if element != 0 
          cell = @field.cell_at(@pos_x + index_x, @pos_y + index_y)
          # cellがnil ⇒ フィールドのグリッド外にはみ出しているので衝突扱いにする
          if cell.nil? || cell.has_block
            return true
          end
        end
      end
    end
    return false
  end

  # 移動操作
  def movement(input)
    case input
    when :left
      try_move_left
    when :right
      try_move_right
    when :rotate_left
      try_rotate_left
    when :rotate_right
      try_rotate_right
    end
  end

  # 落下処理
  # このメソッドが呼ばれる毎に落下カウントを1減算する
  # 落下キーが入力されたか、落下カウントが0以下になったら1セル分落下する
  # 落下したら落下カウントをリセットする
  def fall(input)
    @fall_count -= 1
    if input == :down || @fall_count <= 0
      try_move_down
      @fall_count = FALL_COUNT_INITIAL_VALUE
    end
  end

  # 可能であれば左に移動する
  def try_move_left
    @pos_x -= 1
    if collision?
      @pos_x += 1
    end
  end

  # 可能であれば右に移動する
  def try_move_right
    @pos_x += 1
    if collision?
      @pos_x -= 1
    end
  end

  # 可能であれば左に回転する
  def try_rotate_left
    @rotate -= 1
    if collision?
      @rotate += 1
    end
  end

  # 可能であれば右に回転する
  def try_rotate_right
    @rotate += 1
    if collision?
      @rotate -= 1
    end
  end

  # 可能であれば下に落下する
  # もし着地したら着地メソッドを呼ぶ
  def try_move_down
    @pos_y += 1
    if collision?
      @pos_y -= 1
      landing
    end
  end

  # 着地
  def landing
    # フィールド上のテトリミノの位置のセルにブロックをセットする
    blocks.each_with_index do |sub_array, index_y|
      sub_array.each_with_index do |element, index_x|
        if element != 0
          @field.cell_at(@pos_x + index_x, @pos_y + index_y).set_block(@color)
        end
      end
    end
    # 着地フラグを立てる
    @landed = true
  end
end

次に、7種類の派生テトリミノクラス達TetriminoType*を基底クラスTetriminoを継承して作ります。

TetriminoType*クラスの仕様

  • 初期化時
    • 基底クラスの初期化メソッドを実行する
    • テトリミノの色を設定する
  • 基底クラスのブロックの形状を取得するメソッドをオーバーライドする
    • 回転状態毎のテトリミノ形状を返す
    • 4 * 4の二次元配列で表現する
    • blocks[0][0]の位置が起点(pos_x, pos_yで示される座標の点)となる
    • 値が0の場合はブロック無し
    • 値が1の場合はブロック有り
I字テトリミノ

I.png

~/tetris/lib/TetriminoTypeI.rb
require_relative 'Tetrimino'

# I字テトリミノ 赤色
class TetriminoTypeI < Tetrimino
  def initialize(field)
    super(field)
    @color = :red
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 2
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 1, 1, 1]
      blocks[2] = [0, 0, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [0, 0, 1, 0]
      blocks[1] = [0, 0, 1, 0]
      blocks[2] = [0, 0, 1, 0]
      blocks[3] = [0, 0, 1, 0]
    end
    blocks
  end
end
O字テトリミノ

O.png

~/tetris/lib/TetriminoTypeO.rb
require_relative 'Tetrimino'

# O字テトリミノ(スクエア) 黄色
class TetriminoTypeO < Tetrimino
  def initialize(field)
    super(field)
    @color = :yellow
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    blocks[0] = [0, 0, 0, 0]
    blocks[1] = [0, 1, 1, 0]
    blocks[2] = [0, 1, 1, 0]
    blocks[3] = [0, 0, 0, 0]
    blocks
  end
end
T字テトリミノ

T.png

~/tetris/lib/TetriminoTypeT.rb
require_relative 'Tetrimino'

# T字テトリミノ 水色
class TetriminoTypeT < Tetrimino
  def initialize(field)
    super(field)
    @color = :paleBlue
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 4
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 1, 1, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [0, 1, 0, 0]
      blocks[1] = [1, 1, 0, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 2
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [0, 1, 0, 0]
      blocks[2] = [1, 1, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    when 3
      blocks[0] = [0, 1, 0, 0]
      blocks[1] = [0, 1, 1, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    end
    blocks
  end
end
J字テトリミノ

J.png

~/tetris/lib/TetriminoTypeJ.rb
require_relative 'Tetrimino'

# J字テトリミノ 青色
class TetriminoTypeJ < Tetrimino
  def initialize(field)
    super(field)
    @color = :blue
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 4
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 1, 1, 0]
      blocks[2] = [0, 0, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [0, 1, 0, 0]
      blocks[1] = [0, 1, 0, 0]
      blocks[2] = [1, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 2
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 0, 0, 0]
      blocks[2] = [1, 1, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    when 3
      blocks[0] = [0, 1, 1, 0]
      blocks[1] = [0, 1, 0, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    end
    blocks
  end
end
L字テトリミノ

L.png

~/tetris/lib/TetriminoTypeL.rb
require_relative 'Tetrimino'

# L字テトリミノ 橙色
class TetriminoTypeL < Tetrimino
  def initialize(field)
    super(field)
    @color = :orange
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 4
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 1, 1, 0]
      blocks[2] = [1, 0, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [1, 1, 0, 0]
      blocks[1] = [0, 1, 0, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 2
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [0, 0, 1, 0]
      blocks[2] = [1, 1, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    when 3
      blocks[0] = [0, 1, 0, 0]
      blocks[1] = [0, 1, 0, 0]
      blocks[2] = [0, 1, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    end
    blocks
  end
end
S字テトリミノ

S.png

~/tetris/lib/TetriminoTypeS.rb
require_relative 'Tetrimino'

# S字テトリミノ 紫色
class TetriminoTypeS < Tetrimino
  def initialize(field)
    super(field)
    @color = :purple
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 2
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [0, 1, 1, 0]
      blocks[2] = [1, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [1, 0, 0, 0]
      blocks[1] = [1, 1, 0, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    end
    blocks
  end
end
Z字テトリミノ

Z.png

~/tetris/lib/TetriminoTypeZ.rb
require_relative 'Tetrimino'

# Z字テトリミノ 緑色
class TetriminoTypeZ < Tetrimino
  def initialize(field)
    super(field)
    @color = :green
  end

  # ブロック取得メソッド
  def blocks
    blocks = Array.new(4)
    case @rotate % 2
    when 0
      blocks[0] = [0, 0, 0, 0]
      blocks[1] = [1, 1, 0, 0]
      blocks[2] = [0, 1, 1, 0]
      blocks[3] = [0, 0, 0, 0]
    when 1
      blocks[0] = [0, 0, 1, 0]
      blocks[1] = [0, 1, 1, 0]
      blocks[2] = [0, 1, 0, 0]
      blocks[3] = [0, 0, 0, 0]
    end
    blocks
  end
end

描画処理

フィールドとテトリミノを表現するクラスを作ったので、それらの情報を元に画面を描画するDisplayクラスを作成します。
画面描画にはcursesを使います。
cursesの使い方についてはコードコメントにておおまかな説明はしています。

Displayクラスの仕様

  • 初期化時
    • 画面描画のためにcursesを使う準備をする
  • フィールドとテトリミノをcursesを使って画面に描画するメソッドを持つ
  • cursesによる画面描画を閉じるメソッドを持つ
~/tetris/lib/Display.rb
require 'curses'

# 画面描画
class Display

  SQUARE = '■'

  def initialize
    # cursesによる画面制御の開始
    Curses.init_screen
    # カラー処理を有効化
    Curses.start_color
    # 端末のデフォルトの前景色と背景色を使用するように設定
    Curses.use_default_colors

    # カラーペアを初期化するためのループ
    # cursesは256色をサポートしているので、それぞれのペアを設定する
    (0..255).each do |i|
      # 同じ前景色とデフォルトの背景色を持つカラーペアを初期化する
      # 引数:1番目はペア番号、2番目は前景色、3番目は背景色(-1はデフォルトの背景色)
      Curses.init_pair(i, i, -1)
    end
  end

  # フィールドとテトリミノの情報をもとに画面を描画する
  def draw(field, tetrimino)
    # 画面をクリア
    Curses.erase
    # フィールドの描画
    draw_field(field)
    # テトリミノの描画
    draw_tetrimino(tetrimino)
    # 画面に変更を反映
    Curses.refresh
  end

  # cursesによる画面を閉じる
  def close
    Curses.close_screen
  end

  private

  # フィールドの描画
  def draw_field(field)
    field.grid.each do |cell|
      if cell.has_block
        # ブロック描画
        draw_block(cell.pos_x, cell.pos_y, cell.color)
      end
    end
  end

  # テトリミノの描画
  def draw_tetrimino(tetrimino)
    # テトリミノのブロックを描画する二重ループ
    tetrimino.blocks.each_with_index do |sub_array, index_y|
      sub_array.each_with_index do |element, index_x|
        if element != 0
          # ブロック描画
          draw_block(tetrimino.pos_x + index_x, tetrimino.pos_y + index_y, tetrimino.color)
        end
      end
    end
  end

  # ブロックを描画する
  def draw_block(x, y, color)
    # カラーペア番号を取得する
    color_number = color_number_from_symbol(color)
    return if color_number == 0
    # カラーペアを適用する
    Curses.attron(Curses.color_pair(color_number))
    # カーソル位置を設定する
    # x軸の値をそのまま使うと詰まった様な印象の画面になるので2倍する
    Curses.setpos(y, x * 2)
    # ブロックを描画する
    Curses.addstr(SQUARE)
  end

  # 色シンボルからカラーペア番号を取得する
  def color_number_from_symbol(color_symbol)
    case color_symbol
    when :red
      1
    when :green
      2
    when :yellow
      3
    when :blue
      4
    when :paleBlue
      6
    when :purple
      13
    when :brown
      94
    when :orange
      208
    when :gray
      240
    else
      0
    end
  end
end

ここまでに作成した各クラスを使ってGameクラスを更新します。

Gameクラスに追加する仕様

  • 初期化時に以下を追加する
    • Fieldクラスを初期化する
    • Displayクラスを使えるようにする
    • 7つのテトリミノ派生クラスからランダムに選択して初期化する
  • 以下をフレーム毎に行う事に追加する
    • テトリミノが接地していたらランダムに選択して初期化する
    • テトリミノの状態を更新する
    • 画面を描画する
  • フレームのループから抜けたら、cursesによる画面を閉じる
~/tetris/lib/Game.rb
  require_relative 'PlayerInput'
+ require_relative 'Field'
+ require_relative 'Display'
+ require_relative 'TetriminoTypeI'
+ require_relative 'TetriminoTypeO'
+ require_relative 'TetriminoTypeT'
+ require_relative 'TetriminoTypeJ'
+ require_relative 'TetriminoTypeL'
+ require_relative 'TetriminoTypeS'
+ require_relative 'TetriminoTypeZ'

# ゲーム全体の統括
class Game
  def initialize
    @player_input = PlayerInput.new
+   @field = Field.new
+   @display = Display.new
+   @tetrimino = random_tetrimino
  end

  def run
    #フレームのループ
    loop do
      # キーボードからの入力を取得
      input = @player_input.from_key

      # ESCキーを押されたらループを抜ける
      if input == :exit
        break
      end

+     # テトリミノが着地していたら初期化
+     if @tetrimino.landed
+       @tetrimino = random_tetrimino
+     end
+
+     # テトリミノの更新
+     @tetrimino.update(input)
+
+     # 画面を描画する
+     @display.draw(@field, @tetrimino)

      # だいたい60FPSにするために16ms止める
      sleep(0.016)
    end
+   # cursesによる画面を閉じる
+   @display.close
  end

+ private
+
+ # 7種類のテトリミノからランダムで選び新規オブジェクトを返す
+ def random_tetrimino
+   case rand(7)
+   when 0
+     TetriminoTypeI.new(@field)
+   when 1
+     TetriminoTypeO.new(@field)
+   when 2
+     TetriminoTypeT.new(@field)
+   when 3
+     TetriminoTypeJ.new(@field)
+   when 4
+     TetriminoTypeL.new(@field)
+   when 5
+     TetriminoTypeS.new(@field)
+   when 6
+     TetriminoTypeZ.new(@field)
+   end
+ end
end

この時点で実行すると、テトリミノを操作してフィールドに積み重ねていくことができることを確認できます。

ラインが揃ったら消える処理

ラインが揃っても消えてくれないのは寂しいので、消える様にしていきます。
Fieldクラスに、「横方向のラインが揃ったかを判定して揃っていたら消去するメソッド」を追加します。
ラインが消えた後の段下げも併せて行うので、結構複雑です。

Fieldクラスに追加する仕様

  • 横方向のラインが揃ったかを判定して揃っていたら消去するメソッドを持つ
    • 消去の際、そのラインより上段のラインのブロックを全て1段ずつ下にずらす

(※以下のコードブロックは主に追加した内容だけの抜粋なのでdiff表記しません。)

~/tetris/lib/Field.rb
require_relative 'Cell'

# テトリスのフィールド
class Field

  # 省略 #

  # 消去可能な行を調べて消去する
  def check_and_clear_lines
    # 最下段から最上段までのループ
    GRID_INSIDE_HEIGHT_RANGE.reverse_each do |y|
      # チェック対象のy軸が繋がったラインになっているかを調べて、なっていれば消去する
      # 消した後に上段のブロックを1段ずつ下にずらすので、
      # ずらし後に繋がったラインが来なくなるまでwhile文で繰り返す
      while line_connected?(y)
        clear_line(y)
      end
    end
  end

  private

  # 指定されたy軸のcell配列を返す(壁を除く)
  def line_cells(y)
    cells = Array.new
    @grid.each do |cell|
      if !cell.is_wall && cell.pos_y == y
        cells.push(cell)
      end
    end
    cells
  end

  # 指定されたy軸が繋がったラインになっているかを返す
  def line_connected?(y)
    line_cells(y).each do |cell|
      if !cell.has_block
        return false
      end
    end
    true
  end

  # 指定されたy軸のブロックを消去し、上段のブロックを1段ずつ下にずらす
  def clear_line(y)
    # 指定されたy軸から最上段までのループ
    y.downto(MINIMUM_GRID_INSIDE_HEIGHT) do |work_y|
      # work_y軸のセル毎のループ
      line_cells(work_y).each do |cell|
        # セルのブロックを消去する
        cell.remove_block
        # 1段上のセルを取得する
        one_up_cell = cell_at(cell.pos_x, cell.pos_y - 1)
        # 1段上のセルにブロックがあれば、
        # そのブロックをセルに移動する
        if !one_up_cell.is_wall && one_up_cell.has_block
          cell.set_block(one_up_cell.color)
        end
      end
    end
  end
end

Fieldクラスに新しく定義したメソッドは、テトリミノの着地時に呼ぶようにします。

Tetriminoクラスに追加する仕様

  • 着地処理に以下を追加する
    • フィールドのライン消去判定メソッドを呼ぶ
~/tetris/lib/Tetrimino.rb
require_relative 'Field'

# テトリミノの基底クラス
class Tetrimino

  # 省略 #

  # 着地
  def landing
    # フィールド上のテトリミノの位置のセルにブロックをセットする
    blocks.each_with_index do |sub_array, index_y|
      sub_array.each_with_index do |element, index_x|
        if element != 0
          @field.cell_at(@pos_x + index_x, @pos_y + index_y).set_block(@color)
        end
      end
    end
    # 着地フラグを立てる
    @landed = true
+   # フィールドのライン消去判定メソッドを呼ぶ
+   @field.check_and_clear_lines
  end
end

これでラインが揃ったら消える様になりました。

ゲームオーバー処理

テトリスは新しいテトリミノ出現時にフィールド上のブロックに重なっていたらゲームオーバーになります。
Tetriminoクラスに上記の状況を満たしている事を示すスタックフラグを用意し、初期化時に判定を行います。

Tetriminoクラスに追加する仕様

  • 初期化時に以下を追加する
    • 衝突していたらスタックフラグを立てる
~/tetris/lib/Tetrimino.rb
require_relative 'Field'

# テトリミノの基底クラス
class Tetrimino
- attr_reader :pos_y, :pos_x, :color, :landed
+ attr_reader :pos_y, :pos_x, :color, :landed, :stack

  # 省略 #

  def initialize(field)
    @field = field
    @pos_x = START_POS_X
    @pos_y = START_POS_Y
    @color = :none
    @landed = false
    @rotate = 0
    @fall_count = FALL_COUNT_RESET_VALUE
+   # 初期化時の時点で衝突していたらスタックフラグを立てる
+   @stack = collision?
  end

  # 省略 #

end

ゲームオーバーになったらフィールド上のブロックとテトリミノが全部グレーになるとそれっぽいです。
ブロックを色変えするメソッドをそれぞれのクラスに用意します。

Fieldクラスに追加する仕様

  • フィールド上の全セルの壁以外のブロックの色を指定された色に変更するメソッドを持つ

(※以下のコードブロックは主に追加した内容だけの抜粋なのでdiff表記しません。)

~/tetris/lib/Field.rb
require_relative 'Cell'

# テトリスのフィールド
class Field

  # 省略 #

  # フィールド上の全てのブロックの色を変更する
  def change_all_blocks_color(color)
    @grid.each do |cell|
      if !cell.is_wall && cell.has_block
        cell.set_block(color)
      end
    end
  end

  private

  # 省略 #

end

Tetriminoクラスに追加する仕様

  • テトリミノの色を指定された色に変更するメソッドを持つ

(※以下のコードブロックは主に追加した内容だけの抜粋なのでdiff表記しません。)

~/tetris/lib/Tetrimino.rb
require_relative 'Field'

# テトリミノの基底クラス
class Tetrimino

  # 省略 #

  # テトリミノの色を変更する
  def change_color(color)
    @color = color
  end
  
  private

  # 省略 #

end

Gameクラスにゲームオーバー判定メソッドを用意し、runメソッドのループの中で呼んで判定結果で分岐させます。
ゲームオーバーになったら、ゲームの進行を止めるためにテトリミノの更新はしない様にします。

Gameクラスに追加する仕様

  • ゲームオーバーかを判定するメソッドを持つ
  • 以下をフレーム毎に行う事に追加する
    • ゲームオーバーかの判定を行い、真の場合
      • フィールド上のブロックの色をグレーに変更する
      • テトリミノの色をグレーに変更する
      • テトリミノの初期化と更新は行わない様にする
~/tetris/lib/Game.rb
# 省略 #

# ゲーム全体の統括
class Game

  # 省略 #

  def run
    #フレームのループ
    loop do
      # キーボードからの入力を取得
      input = @player_input.from_key

      # ESCキーを押されたらループを抜ける
      if input == :exit
        break
      end

-     # テトリミノが着地していたら初期化
-     if @tetrimino.landed
-       @tetrimino = random_tetrimino
-     end
-
-     # テトリミノの更新
-     @tetrimino.update(input)
+     # ゲームオーバーの判定
+     if game_over?
+       @field.change_all_blocks_color(:gray)
+       @tetrimino.change_color(:gray)
+     else
+       # テトリミノが着地していたら初期化
+       if @tetrimino.landed
+         @tetrimino = random_tetrimino
+       end
+
+       # テトリミノの更新
+       @tetrimino.update(input)
+     end

      # 画面を描画する
      @display.draw(@field, @tetrimino)

      # だいたい60FPSにするために16ms止める
      sleep(0.016)
    end
    # cursesによる画面を閉じる
    @display.close
  end

  private

+ # ゲームオーバーの判定
+ def game_over?
+   @tetrimino.stack
+ end

  # 省略 #

end

完成!!

繰り返しになりますが、以下のコマンドで実行出来ます。
気が済むまで動作確認とデバッグを繰り返しましょう。

Ubuntu
ruby ~/tetris/tetris.rb

課題

このテトリスは以下のフィーチャーが満たせていません。

  • スコア
  • レベルアップ(テトリミノ落下の猶予時間短縮)
  • 次のテトリミノの表示
  • テトリミノの偏り補正
  • cursesに頼らないキビキビとした操作性
    • cursesだと押しっぱなしの入力がテキスト入力準拠の挙動になってしまう・・・。
  • ラインを消した時の演出
  • 着地時のウェイト
  • テトリミノのホールド
  • 落下地点の表示
  • ハードドロップ
  • ゲームオーバー時に現れる踊る猿

リポジトリにソースコードがあるので、これらの追加を試みてみるのも面白いかも知れません。(丸投げ)

あとがき

長い記事をここまで見ていただいてありがとうございます。
今回、この記事を書くことを決めたはよいものの、テトリス制作からは4ヶ月ほど経っていました。
何をどうしたのか全く覚えていなかったので、思い出すためにイチから作り直しながら記事を書くというスタイルで挑みました。
思っていたより時間が掛かりましたが、その甲斐あってRubyへの理解度が飛躍的に向上した感覚があります。アウトプットはいいぞ。

普段C#を扱っている身としては、Rubyの独特な書き心地に最初は戸惑いましたが、慣れてくるといじらしくかわいらしい感じがしてたいへん楽しかったです。好きな言語に挙げる人が多いのもわかる。
次は2Dゲーム用のライブラリを使って何か作ってみたいなと思いました。
cursesを使う方が比較的簡単と書きましたが、あれは嘘です。クセ強過ぎ。

参考記事

制作にあたり参考にさせていただいた記事の一覧です。ありがとうございました。

22
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?