この記事は株式会社ラグザイア 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上でusername
とpassword
の登録を要求されるので入力します。(ここで何を入力したか忘れない様に!)
なお、password
は入力しても画面には表示されませんが、内部的には入力されています。
登録後、しばらく待って以下の様なコマンドライン入力待ち状態になればOKです。(緑色の文字列部分は[ユーザー名]@[PC名]になります。)
この時点で既にWindowsのスタートメニューにUbuntuが追加されているはずなので、今後はそれを実行することでこの状態に戻ってくることが出来ます。
また、エクスプローラーのナビゲーションウィンドウに追加されているペンギンアイコン(Linux)からUbuntuのファイルシステムを操作することが出来ます。
エクスプローラーから見る場合のホームディレクトリ(Ubuntu起動時の初期ディレクトリ)の位置は以下になります。
\\wsl.localhost\Ubuntu\home\<ユーザー名>
これでWindows上にLinux環境(Ubuntu環境)を作ることが出来ました。
Rubyのインストール
WSL2上のUbuntuにRubyをインストールします。
Ubuntu標準のパッケージ管理システムのapt
やapt-get
でもRubyをインストールすることが出来ますが、それだと少し古いバージョンがインストールされてしまいます。
折角なら出来るだけ新しいバージョンのRubyを使いたい & 複数のバージョンのRubyを切り替えられる方が便利なので、今回は少々導入時の手間が増えてしまいますが、rbenv
というRubyのバージョン管理ツール経由でインストールを行います。
Ubuntuで以下のコマンドを上から1行ずつ順番に実行し、rbenv
を使える様にします。
複雑そうに見えるかも知れませんが、順番に実行するだけで大丈夫です。
もし途中でパスワードの入力を求められたら先ほど登録したパスワードを入力してください。
# パッケージリストを更新する
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のバージョン一覧が表示されます。
rbenv install -l
今回、筆者は以下のコマンドで3.1.4と3.2.2の二つのバージョンのRubyをインストールしました。
複数バージョンの導入が不要の場合は、最新安定版だけのインストールで問題ありません。
(筆者の場合、受験予定だったRuby試験で対象となるバージョンが3.1.4だったので、本記事公開時点の最新安定版である3.2.2と併せてインストールしました。)
インストールには数分程の時間が掛かる場合があるのでじっくり待ちましょう。
rbenv install 3.1.4
rbenv install 3.2.2
以下のコマンドで、rbenv
経由でインストールしたRubyのバージョン一覧を確認できます。
rbenv versions
以下のコマンドで、ruby
コマンドで使われるRubyのバージョンの指定ができます。
rbenv global 3.1.4
rbenv global 3.2.2
以下のコマンドで、ruby
コマンドで使われるRubyのバージョンを確認できます。
ruby -v
Rubyのバージョンをコマンドひとつで切り替えられる様になりました。
これでRubyのインストールができました。
この先はバージョン3.2.2のRubyを使って進めます。
Hello world!
インストールしたRubyを使ってプログラムが実行出来ることを確認します。
作業用のディレクトリを作成し、その中にrbファイル(Rubyのコードを書くファイル)を作成します。
mkdir ~/hello
touch ~/hello/world.rb
Vimやnano等のテキストエディタを使って、作成したworld.rb
に以下のコードを書き込みます。
または、エクスプローラーからファイルを参照する事で、Windowsにインストールされているお好みのエディタを使ってファイルを編集する事も可能です。(Linux環境では改行コードをLFで統一する様に注意してください。)
puts 'Hello world!'
実行します。
ruby ~/hello/world.rb
Hello world!
これでRuby開発環境の構築が出来ました。
Rubyのお勉強
Rubyの開発環境が作れても、Rubyプログラムの書き方を知らなければ何も作れません。
筆者は以下の書籍を読んで勉強しました。
↓ プログラミングそのものが初心者の方にオススメの本です。
↓ Ruby以外のプログラミング言語での開発経験がある方にはこの本がオススメです。
↓ 初学者に優しい内容ではありませんが、Rubyの資格取得を目指すなら必須級の本です。
Web上にもRuby入門用のサイトやサービスがたくさんあります。
自分に合いそうな勉強方法を選ぶのがよいと思います。
テトリス開発
筆者は新しくプログラミング言語を学習する際はとりあえずテトリスを作る様にしています。
理由は楽しいからです。
完成したらこんな感じ
完成後のソースコード(GitHubリポジトリ)
完成後の動画
開発を始める前にcursesをインストールする
テトリスというゲームはリアルタイムに画面が更新されないといけません。
かといって、2Dゲーム用のgem(Rubyにおける外部ライブラリ)を使用するというのは初学者である筆者にはハードルが高いと感じました。
そこで、比較的簡単に画面の更新が実現出来るcurses
というgemを使用します。
curses
を使うと、VimやRogueの様なテキストベースのユーザーインターフェースを作成できます。
テトリスの様な見た目がシンプルでも大丈夫なゲームなら、これを使って作ることが出来ます。
以下のコマンドでcurses
gemをインストールします。
# curses gemのビルドに必要なパッケージをインストールする
sudo apt-get install -y libncurses5-dev libncursesw5-dev
# デフォルトでインストールされているgemを更新する
gem update
# curses gemをインストールする
gem install curses
cursesを試しに使ってみる
先ほどのHello world!プログラムをcurses
を使う形に書き直してみます。
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による画面制御を終了
実行します。
ruby ~/hello/world.rb
4行目の左から11文字目にHello world!
とだけ表示されることを確認できます。
何かキーを押すと元の画面に戻ります。
Hello world!
画面が更新されるプロセスについて
-
init_screen
(初期化) -
erase
(画面クリア) -
setpos
(カーソル位置指定) -
addstr
(文字列書き込み) -
refresh
(画面を反映) - 画面を再描画する場合、2から繰り返す(画面の一部のみ書き換えの場合は3から)
上記のプロセスが理解できればとりあえず画面描画処理を書くことが出来ます。
終了時はclose_screen
を呼んで閉じることを忘れない様にしてください。
本記事ではcurses
の詳細な使い方の説明は行いません。
もし興味があれば、本記事の末尾に参考記事へのリンクがあるので、そちらを読んでみてください。
クラス設計
では、テトリスを作り始めます。
行き当たりばったりで作り始めるのも冒険感があって楽しいですが、「こんな感じに作ろ~」っていうのをあらかじめ考えてから作り始める方がゴールが見え易くなり、挫折率をある程度下げられます。
この「あらかじめ考える」ことを設計といいます。
Rubyはオブジェクト指向型言語なので、先にクラス設計すると作り易くなります。
ざっくりとどんな役割やモノが登場して、それらがどう関係するかを考えます。
テトリスを構成するクラス達を考えた結果、こんな感じになりました。(雑ですがクラス図のつもり)
-
Game
- ゲーム全体を統括するクラス
-
PlayerInput
- プレイヤーからの入力を取得するクラス
-
Field
- テトリスの盤面を表すクラス
-
Cell
- Fieldを構成する要素クラス
-
Display
- 画面を描画するクラス
-
Tetrimino
- テトリミノ(プレイヤーが操作する上から降ってくるブロック)の基底クラス
-
TetriminoType*
- 7種類のテトリミノそれぞれを表すクラス(
Tetrimino
を継承する派生クラス)
- 7種類のテトリミノそれぞれを表すクラス(
これらのクラスをひとつずつコーディングしていくことでテトリスが出来上がります。
開発準備
テトリスを作るための作業用ディレクトリを作ります。
以下のコマンドでtetris
というディレクトリを作成します。
mkdir ~/tetris
ここに実行用rbファイルとして、tetris.rb
を作成します。(touch
コマンドでのファイル作成は以後省略します。)
コードの中でGame
というまだ未定義のクラスを使っていますが、この後で作ります。
require_relative 'lib/Game'
game = Game.new
game.run
tetris
ディレクトリの中に、更にlib
ディレクトリを作ります。
ゲームの本体部分はこの中に作成していきます。
mkdir ~/tetris/lib
lib
ディレクトリの中に、Game
クラスを定義するrbファイルを作成します。
Game
クラスはゲーム全体を統括するクラスになります。
run
メソッドの中身は、今はとりあえず空っぽで。
# ゲーム全体の統括
class Game
def run
end
end
今後、以下のコマンドでプログラムを実行出来ます。
この段階では実行しても何も起こりません。
ruby ~/tetris/tetris.rb
プレイヤーの入力処理
テトリスはゲームなので、プレイヤーが入力操作を行える様になっていないといけません。
プレイヤーからの入力を取得するPlayerInput
クラスを作成します。
curses
は画面表示だけでは無くキーボードからの入力処理もサポートしているので、それを使って作ります。
PlayerInputクラスの仕様
- 初期化時
- リアルタイムなキー入力取得のために
curses
を使う準備をする
- リアルタイムなキー入力取得のために
- どのキーが入力されているかを取得するメソッドを持つ
- プレイヤーが任意にゲームを終了出来る様に、エスケープキーの入力を取得出来るようにする
- 方向の入力は、いわゆるWASD方式にする
- テトリミノを回転する操作があるので、それぞれ
J
を左回転,K
を右回転に割り当てる
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にするため)
- フレーム毎に以下の事を行う様にする
+ 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個のセルを持ったグリッドを用意します。
まずは、グリッドを構成するセル単体を表現するCell
クラスを作成します。
Cellクラスの仕様
- 状態
- 座標情報
- 横方向(x軸)何番目のセルか
- 縦方向(y軸)何番目のセルか
- セルは壁ブロックか
- セルにブロックが存在するか
- ブロックが何色か
- 座標情報
- セルにブロックをセットするメソッドを持つ
- セルのブロックを消去するメソッドを持つ
# テトリスのフィールドを構成するセル
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
になるようにする
- 指定した座標のセルを取得するメソッドを持つ
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になったら落下する
- プレイヤーからの下方向の入力を検知したら落下カウントに関わらず落下する
- 落下したら落下カウントをデフォルト値に戻す
- 落下後、衝突を検知したらすぐに元の位置に戻して着地処理を実行する
- 着地処理
- フィールド上の着地時のテトリミノの位置にブロックを配置する
- 着地フラグを立てる
- 左右移動処理
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字テトリミノ
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字テトリミノ
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字テトリミノ
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字テトリミノ
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字テトリミノ
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字テトリミノ
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字テトリミノ
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
による画面描画を閉じるメソッドを持つ
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
による画面を閉じる
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表記しません。)
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クラスに追加する仕様
- 着地処理に以下を追加する
- フィールドのライン消去判定メソッドを呼ぶ
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クラスに追加する仕様
- 初期化時に以下を追加する
- 衝突していたらスタックフラグを立てる
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表記しません。)
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表記しません。)
require_relative 'Field'
# テトリミノの基底クラス
class Tetrimino
# 省略 #
# テトリミノの色を変更する
def change_color(color)
@color = color
end
private
# 省略 #
end
Game
クラスにゲームオーバー判定メソッドを用意し、run
メソッドのループの中で呼んで判定結果で分岐させます。
ゲームオーバーになったら、ゲームの進行を止めるためにテトリミノの更新はしない様にします。
Gameクラスに追加する仕様
- ゲームオーバーかを判定するメソッドを持つ
- 以下をフレーム毎に行う事に追加する
- ゲームオーバーかの判定を行い、真の場合
- フィールド上のブロックの色をグレーに変更する
- テトリミノの色をグレーに変更する
- テトリミノの初期化と更新は行わない様にする
- ゲームオーバーかの判定を行い、真の場合
# 省略 #
# ゲーム全体の統括
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
完成!!
繰り返しになりますが、以下のコマンドで実行出来ます。
気が済むまで動作確認とデバッグを繰り返しましょう。
ruby ~/tetris/tetris.rb
課題
このテトリスは以下のフィーチャーが満たせていません。
- スコア
- レベルアップ(テトリミノ落下の猶予時間短縮)
- 次のテトリミノの表示
- テトリミノの偏り補正
-
curses
に頼らないキビキビとした操作性-
curses
だと押しっぱなしの入力がテキスト入力準拠の挙動になってしまう・・・。
-
- ラインを消した時の演出
- 着地時のウェイト
- テトリミノのホールド
- 落下地点の表示
- ハードドロップ
- ゲームオーバー時に現れる踊る猿
リポジトリにソースコードがあるので、これらの追加を試みてみるのも面白いかも知れません。(丸投げ)
あとがき
長い記事をここまで見ていただいてありがとうございます。
今回、この記事を書くことを決めたはよいものの、テトリス制作からは4ヶ月ほど経っていました。
何をどうしたのか全く覚えていなかったので、思い出すためにイチから作り直しながら記事を書くというスタイルで挑みました。
思っていたより時間が掛かりましたが、その甲斐あってRubyへの理解度が飛躍的に向上した感覚があります。アウトプットはいいぞ。
普段C#を扱っている身としては、Rubyの独特な書き心地に最初は戸惑いましたが、慣れてくるといじらしくかわいらしい感じがしてたいへん楽しかったです。好きな言語に挙げる人が多いのもわかる。
次は2Dゲーム用のライブラリを使って何か作ってみたいなと思いました。
curses
を使う方が比較的簡単と書きましたが、あれは嘘です。クセ強過ぎ。
参考記事
制作にあたり参考にさせていただいた記事の一覧です。ありがとうございました。