テトリスの仕上げ
前回からの続き。
テトリスとしての基本機能は実装できた。テトリスが動作するサンプルコードとしては十分なのだけど、一般的なゲームとしてはリリースできるレベルではない。最低限以下の機能が不足している。
- 得点や次のブロックを表示する機能も欲しい。
- ゲーム中の一時停止と再開の機能も欲しい。
- ゲームオーバー後は終了するしかなかったが、終了せずにゲームをやり直す機能も欲しい。
以上の機能は、サンプルコードのfalling_blocks(テトリス)ではちゃんと実装されている。ゲームを作る時の雛形として理解しておきたいので、自分でも実装してみようと思う。そう思って作業を始めて、すぐに気付いた。実はこうした付属する機能を追加するのは、想像以上に手間がかかる。
これまでは、game.screen = テトリスフィールド(ブロックを積み上げるエリア)だった。今回はgame.screenに、得点や次のブロックを表示するためのエリアを追加する必要がある。どのようにして、必要な情報をそれぞれのエリアに表示すればいいのだろう?どのようなオブジェクトを用意して、役割分担させるべきなのか?悩む...。
また、ゲームの一時停止、再開、ゲームオーバー後のやり直し等は、どのようなゲームも備えるべき共通の処理であり、ゲーム環境のOS的な側面がある。共通の処理としてどのように独立させるべきなのか?悩む...。
得点や次のブロックを表示する機能の追加
UIのデザイン
- まずは、得点や次のブロックをどのように表示するべきか?考えておく必要がある。
- 表計算アプリの方眼用紙でデザインしてみた。
- 4つのエリアに分けてみた。
- テトリスフィールド(ブロックを積み上げるエリア)
- SCORE(得点を表示するエリア)
- LINES(消去した段数を表示するエリア)
- NEXT(次のブロックを表示するエリア)
- 何の工夫もないのだけど、4つのエリアに分けてゲーム情報を出力することを目指す。
- 各エリアの位置や大きさは、ブロック単位(32×32)で行った。
Frameクラスの追加(diff)
- 上記UIデザインを実現するために、game.screenの大きさを拡大した。(26行×18列)
- 4つのエリアを取りまとめるクラスとして、Frameクラスを追加した。
- Frameは、各エリアの位置や大きさを保持している。
- Frameは、各エリアを描画することに対して責任を持つ。
- game.screenへの描画は、Frameを介して行う。
...中略... WINDOW_ROW = 26 WINDOW_COL = 18 WINDOW_W = BLOCK_SIZE * WINDOW_COL WINDOW_H = BLOCK_SIZE * WINDOW_ROW ...中略... class Frame def initialize(screen) @screen = screen @field_view = Texture.new(FIELD_W, FIELD_H) @score_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @lines_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @next_view = Texture.new(FIELD_W, 2 * BLOCK_SIZE) end def update(sender) @field_view.fill(Color.new(0, 0, 0, 128)) @score_view.fill(Color.new(0, 0, 0, 128)) @lines_view.fill(Color.new(0, 0, 0, 128)) @next_view.fill(Color.new(0, 0, 0, 128)) @field = sender.instance_variable_get(:@field) @tetrimino = sender.instance_variable_get(:@tetrimino) @field_view.draw_field(@field) @field_view.draw_tetrimino(@tetrimino) @screen.fill(Color.new(255, 255, 255)) @screen.render_texture(@field_view, 1 * BLOCK_SIZE, 5 * BLOCK_SIZE) @screen.render_texture(@score_view, 13 * BLOCK_SIZE, 6 * BLOCK_SIZE) @screen.render_texture(@lines_view, 13 * BLOCK_SIZE, 10 * BLOCK_SIZE) @screen.render_texture(@next_view , 1 * BLOCK_SIZE, 2 * BLOCK_SIZE) end end Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| ...中略... @frame ||= Frame.new(game.screen) ...中略... @frame.update(self) end
- 以下の手順で、複数エリアへ描画している。
- 表示エリアごとにTextureを生成する。
- エリアごとのTextureに対して、必要な描画をしておく。
- 最後にgame.screenに対して、エリアごとのTextureを描画する。
- とりあえずテトリスフィールドのみ、描画を更新するようにしておく。
- その他のエリアは、その領域をグレー表示するだけにとどめた。
各エリア名をラベル表示する(diff)
- フォントを追加
- ブロック単位でテキスト表示するdraw_textを追加
- Frameクラスでラベルを描画するフォントを追加
...中略... FONT = Font.new("/Library/Fonts/Arial Bold.ttf", 24) class Texture ...中略... def draw_text(str, col, row) render_text(str, col * BLOCK_SIZE, row * BLOCK_SIZE, FONT, Color.new(0, 0, 0)) end class Frame ...中略... def update(sender) ...中略... @screen.fill(Color.new(255, 255, 255)) @screen.render_texture(@field_view, 1 * BLOCK_SIZE, 5 * BLOCK_SIZE) @screen.draw_text("SCORE", 13, 5) @screen.render_texture(@score_view, 13 * BLOCK_SIZE, 6 * BLOCK_SIZE) @screen.draw_text("LINES", 13, 9) @screen.render_texture(@lines_view, 13 * BLOCK_SIZE, 10 * BLOCK_SIZE) @screen.draw_text("NEXT", 4, 1) @screen.render_texture(@next_view , 1 * BLOCK_SIZE, 2 * BLOCK_SIZE) end end ...中略...
SCOREとLINESを表示する(diff)
- 数値を右下寄せで表示するdraw_numberメソッドを追加。
- FrameクラスでSCOREとLINESを描画する。
- Game.runループで、@score_counterと@lines_counterを計測する。
次のブロックを表示する(diff)
- Frameクラスで、次のブロックを描画する。
- game.runループで、@nextminoを追加する。
- ブロックが積み上がる度に、@tetriminoと@nextminoの入れ替えを行う。
以上で、得点や次のブロックを表示できるようになった!
ここまでのコード
require "starruby" include StarRuby BLOCK_SIZE = 32 FIELD_ROW = 20 FIELD_COL = 10 FIELD_W = BLOCK_SIZE * FIELD_COL FIELD_H = BLOCK_SIZE * FIELD_ROW WINDOW_ROW = 26 WINDOW_COL = 18 WINDOW_W = BLOCK_SIZE * WINDOW_COL WINDOW_H = BLOCK_SIZE * WINDOW_ROW RGBS = [[ 0, 255, 255], [255, 255, 0], [ 0, 255, 0], [255, 0, 0], [ 0, 0, 255], [255, 127, 0], [255, 0, 255]] FONT = Font.new("/Library/Fonts/Arial Bold.ttf", 24) class Texture def draw_block(x, y, rgb) render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(*rgb)) end def draw_tetrimino(tetrimino) return if !tetrimino tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(tetrimino.x + c , tetrimino.y + r, RGBS[tetrimino.id]) if col == 1 end end end def draw_field(field) return if !field alpha = (field.state == :live ? 255 : 64) field.matrix.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(c, r, RGBS[col] + [alpha]) if col != nil end end end def draw_text(str, col, row) render_text(str, col * BLOCK_SIZE, row * BLOCK_SIZE, FONT, Color.new(0, 0, 0)) end def draw_number(num) font_width, font_height = FONT.get_size(num.to_s) margin_width, margin_height = 5, 0 x = self.width - font_width - margin_width y = self.height - font_height - margin_height render_text(num.to_s, x, y, FONT, Color.new(0, 0, 0)) end end class Tetrimino attr_reader :id, :state, :x, :y @@minos = [] @@minos << [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]] @@minos << [[1,1], [1,1]] @@minos << [[0,1,1], [1,1,0], [0,0,0]] @@minos << [[1,1,0], [0,1,1], [0,0,0]] @@minos << [[1,0,0], [1,1,1], [0,0,0]] @@minos << [[0,0,1], [1,1,1], [0,0,0]] @@minos << [[0,1,0], [1,1,1], [0,0,0]] def initialize(game, field) @game = game @field = field @id = rand(0..6) @blocks = @@minos[@id] @x, @y, @angle = 3, 0, 0 @state = :live @last_chance = 0 end def blocks(angle = @angle) case angle % 4 when 0 @blocks when 1 @blocks.transpose.map(&:reverse) #右90度回転 when 2 @blocks.reverse.map(&:reverse) #180度回転 when 3 @blocks.transpose.reverse #左90度回転(右270度回転) end end def rotate(dr) if can_move?(0, 0, dr) then @angle += dr end end def side_step(dx) if can_move?(dx, 0, 0) then @x += dx end end def fall(dy) @last_chance -= 1 @y = @y.to_i if dy == 1 if can_move?(0, 1, 0) then @y += dy else case when @last_chance < 0 @last_chance = @game.fps / 2 when @last_chance == 0 @state = :dead end end end def can_move?(dx, dy, dr) x = @x + dx y = @y + dy angle = @angle + dr blocks(angle).each_with_index do |row, r| row.each_with_index do |col, c| if col == 1 then if x + c < 0 || x + c >= FIELD_COL || y + r >= FIELD_ROW || @field.matrix[y + r][x + c] != nil then return false end end end end true end end class Field attr_reader :matrix, :state def initialize(row, col) @matrix = Array.new(row){Array.new(col)} @state = :live end def import(tetrimino) tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| @matrix[tetrimino.y + r][tetrimino.x + c] = tetrimino.id if col == 1 end end end def clear_lines @matrix.reject!{|row| !row.include?(nil)} deleted_line = FIELD_ROW - @matrix.size deleted_line.times{@matrix.unshift(Array.new(10){nil})} deleted_line end def freeze @state = :dead end end class Frame def initialize(screen) @screen = screen @field_view = Texture.new(FIELD_W, FIELD_H) @score_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @lines_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @next_view = Texture.new(FIELD_W, 2 * BLOCK_SIZE) end def update(sender) @field_view.fill(Color.new(0, 0, 0, 128)) @score_view.fill(Color.new(0, 0, 0, 128)) @lines_view.fill(Color.new(0, 0, 0, 128)) @next_view.fill(Color.new(255, 255, 255, 128)) @field = sender.instance_variable_get(:@field) @tetrimino = sender.instance_variable_get(:@tetrimino) @score_counter = sender.instance_variable_get(:@score_counter) @lines_counter = sender.instance_variable_get(:@lines_counter) @nextmino = sender.instance_variable_get(:@nextmino) @field_view.draw_field(@field) @field_view.draw_tetrimino(@tetrimino) @score_view.draw_number(@score_counter) @lines_view.draw_number(@lines_counter) @next_view.draw_tetrimino(@nextmino) @screen.fill(Color.new(255, 255, 255)) @screen.render_texture(@field_view, 1 * BLOCK_SIZE, 5 * BLOCK_SIZE) @screen.draw_text("SCORE", 13, 5) @screen.render_texture(@score_view, 13 * BLOCK_SIZE, 6 * BLOCK_SIZE) @screen.draw_text("LINES", 13, 9) @screen.render_texture(@lines_view, 13 * BLOCK_SIZE, 10 * BLOCK_SIZE) @screen.draw_text("NEXT", 4, 1) @screen.render_texture(@next_view , 1 * BLOCK_SIZE, 2 * BLOCK_SIZE) end end @score_counter = 0 @lines_counter = 0 Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| @field ||= Field.new(FIELD_ROW, FIELD_COL) @nextmino ||= Tetrimino.new(game, @field) @tetrimino ||= Tetrimino.new(game, @field) @frame ||= Frame.new(game.screen) dx = 0 dy = 0.0625 dr = 0 break if Input.keys(:keyboard).include?(:escape) dx = 1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 1}).include?(:right) dx = -1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 1}).include?(:left) dy = 1 if Input.keys(:keyboard, {:duration =>-1, :delay =>-1, :interval => 0}).include?(:down) dr = 1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 3}).include?(:x) dr = 3 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 3}).include?(:z) next if @field.state == :dead @tetrimino.rotate(dr) @tetrimino.side_step(dx) @tetrimino.fall(dy) if @tetrimino.state == :dead then @field.import(@tetrimino) n = @field.clear_lines @score_counter += n**2 * 100 @lines_counter += n @field.freeze if @tetrimino.y <= 0 @tetrimino = @nextmino @nextmino = nil end @frame.update(self) end
一時停止・再開・ゲームオーバー後に終了せずにゲームをやり直す機能を追加
- 追加する操作
- ゲーム中に、スペースキーで、一時停止と再開
- ゲームオーバー場面で、スペースキーで、ゲームを最初からやり直し
- ゲーム中の操作は二つに分けられる
- テトリスで遊ぶための操作
- 矢印キーでテトリミノを左右に動かす、下に落とす。
- ZXキーでテトリミノを回転させる。
- テトリスで遊ぶための操作
-
- ゲーム全体をコントロールするための操作
- ESCキーで終了
- 一時停止と再開
- ゲームオーバー後に終了せずにゲームをやり直す
- ゲーム全体をコントロールするための操作
- ゲーム中の状態
- 稼働中(:play)
- 一時停止中(:pause)
- ゲームオーバー(:gameover)
以上の用件を実装するために、Dealerクラスを追加した。
- Dealerクラスは、ゲーム全体のコントローラー
Game.runループ内の処理をDealerクラスに移管(diff)
- 現在のGame.runループ内のほとんどの処理を、Dealerクラスに移管する。
...中略... class Dealer def initialize(game) @game = game @state = :play @field = Field.new(FIELD_ROW, FIELD_COL) @nextmino = Tetrimino.new(game, @field) @tetrimino = Tetrimino.new(game, @field) @frame = Frame.new(game.screen) @score_counter = 0 @lines_counter = 0 end def update case @state when :play then play when :pause then pause when :gameover then gameover end end def play dx = 0 dy = 0.0625 dr = 0 dx = 1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 1}).include?(:right) dx = -1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 1}).include?(:left) dy = 1 if Input.keys(:keyboard, {:duration =>-1, :delay =>-1, :interval => 0}).include?(:down) dr = 1 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 3}).include?(:x) dr = 3 if Input.keys(:keyboard, {:duration => 1, :delay => 3, :interval => 3}).include?(:z) return if @field.state == :dead @tetrimino.rotate(dr) @tetrimino.side_step(dx) @tetrimino.fall(dy) if @tetrimino.state == :dead then @field.import(@tetrimino) n = @field.clear_lines @score_counter += n**2 * 100 @lines_counter += n @field.freeze if @tetrimino.y <= 0 @tetrimino = @nextmino @nextmino = Tetrimino.new(@game, @field) end @frame.update(self) end def pause end def gameover end end Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| @dealer ||= Dealer.new(game) break if Input.keys(:keyboard).include?(:escape) @dealer.update end
スペースキーで状態を遷移(diff)
- :play、:pause、:gameover、:reset(追加した)の4つの状態を定義した。
- toggle_stateメソッドによって、上記4つの状態を変化させる。
instance_variable_getをやめ、attr_readerを活用(diff)
- attr_readerを活用して、以下のようにシンプルにアクセスできるようになった。
class Frame ...中略... def update(sender) ...中略... @field_view.draw_field(sender.field) @field_view.draw_tetrimino(sender.tetrimino) @score_view.draw_number(sender.score_counter) @lines_view.draw_number(sender.lines_counter) @next_view.draw_tetrimino(sender.nextmino) ...中略... class Dealer attr_reader :field, :nextmino, :tetrimino, :score_counter, :lines_counter
状態分岐をcaseからsendに変更(diff)
class Dealer ...中略... def update case @state when :play then play when :pause then pause when :gameover then gameover when :reset then reset end end
- 上記のようなcase構文は、たった1行のsendメソッドで用が足りる。
class Dealer ...中略... def update send(@state) end
toggle_stateでの状態遷移でifからcaseに変更(diff)
- コードの見通しを良くするため、ifからcaseに変更した。
テトリミノが1行目から落下するように修正(diff)
- テトリミノがいきなりFieldの1、2行目に出現していたが、1行目から落下が始まるように修正した。
消去したLINESに応じて落下を速める(diff)
- ゲーム性を高めるため、ブロックを消去した段数が増えるごとに、テトリミノの落下スピードを速めるようにした。
正方形のテトリミノが中央から出現するように修正(diff)
- テトリミノの横幅を考慮して、Field中央から出現するように修正した。
- 正方形のテトリミノは、2×2サイズ。
- その他のテトリミノは、3×3または4×4サイズ。
- このサイズ違いによって、出現する位置が左側に寄ってしまっていた。
消去するラインをフラッシュする(diff)
- ブロックで満たされた段を消去する前に、ブロックを光らせる演出を追加した。
- ブロックの色を素早く切り替えて、チカチカさせている。
- この演出のために、Fieldクラスにflash_linesメソッドを追加した。
- また、:flash・:clear2つの状態も追加した。
class Field ...中略... def flash_lines @flash_counter -= 1 case when @flash_counter < 0 @flash_counter = @game.fps / 2 @state = :flash when @flash_counter == 0 @state = :clear end @matrix.each do |row| row.map! {|i| i = @flash_counter % 2 + 7} if !row.include?(nil) end end
- それをDealer側で、以下のようにコントロールする。
class Dealer ...中略... def play ...中略... @tetrimino.rotate(dr) @tetrimino.side_step(dx) @tetrimino.fall(dy) if @tetrimino.state == :dead then @field.import(@tetrimino) @field.flash_lines if @field.state == :clear then n = @field.clear_lines @score_counter += n**2 * 100 @lines_counter += n @field.freeze if @tetrimino.y <= 0 @tetrimino = @nextmino @nextmino = Tetrimino.new(@game, @field) end end @frame.update(self) end
ここまでのコード
require "starruby" include StarRuby BLOCK_SIZE = 32 FIELD_ROW = 20 FIELD_COL = 10 FIELD_W = BLOCK_SIZE * FIELD_COL FIELD_H = BLOCK_SIZE * FIELD_ROW WINDOW_ROW = 26 WINDOW_COL = 18 WINDOW_W = BLOCK_SIZE * WINDOW_COL WINDOW_H = BLOCK_SIZE * WINDOW_ROW RGBS = [[ 0, 255, 255], [255, 255, 0], [ 0, 255, 0], [255, 0, 0], [ 0, 0, 255], [255, 127, 0], [255, 0, 255], [255, 255, 255], [255, 255, 192]] FONT = Font.new("/Library/Fonts/Arial Bold.ttf", 24) class Texture def draw_block(x, y, rgb) render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(*rgb)) end def draw_tetrimino(tetrimino, offset_x = 0, offset_y = 0) return if !tetrimino tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(tetrimino.x + c + offset_x , tetrimino.y + r + offset_y, RGBS[tetrimino.id]) if col == 1 end end end def draw_field(field) return if !field field.matrix.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(c, r, RGBS[col]) if col != nil end end end def draw_text(str, col, row) render_text(str, col * BLOCK_SIZE, row * BLOCK_SIZE, FONT, Color.new(0, 0, 0)) end def draw_number(num) font_width, font_height = FONT.get_size(num.to_s) margin_width, margin_height = 5, 0 x = self.width - font_width - margin_width y = self.height - font_height - margin_height render_text(num.to_s, x, y, FONT, Color.new(0, 0, 0)) end def draw_message(str) font = Font.new("/Library/Fonts/Arial Bold.ttf", 36) font_width, font_height = font.get_size(str) x = (self.width - font_width ) / 2 y = (self.height - font_height) / 2 render_text(str, x, y, font, Color.new(255, 255, 255)) end end class Tetrimino attr_reader :id, :state, :x, :y @@minos = [] @@minos << [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]] @@minos << [[1,1], [1,1]] @@minos << [[0,1,1], [1,1,0], [0,0,0]] @@minos << [[1,1,0], [0,1,1], [0,0,0]] @@minos << [[1,0,0], [1,1,1], [0,0,0]] @@minos << [[0,0,1], [1,1,1], [0,0,0]] @@minos << [[0,1,0], [1,1,1], [0,0,0]] def initialize(game, field) @game = game @field = field @id = rand(0..6) @blocks = @@minos[@id] @x, @y, @angle = (4 - @blocks.size) / 2 + 3, -1, 0 @state = :live @last_chance = 0 end def blocks(angle = @angle) case angle % 4 when 0 @blocks when 1 @blocks.transpose.map(&:reverse) #右90度回転 when 2 @blocks.reverse.map(&:reverse) #180度回転 when 3 @blocks.transpose.reverse #左90度回転(右270度回転) end end def rotate(dr) if can_move?(0, 0, dr) then @angle += dr end end def side_step(dx) if can_move?(dx, 0, 0) then @x += dx end end def fall(dy) @last_chance -= 1 @y = @y.to_i if dy == 1 if can_move?(0, 1, 0) then @y += dy else case when @last_chance < 0 @last_chance = @game.fps / 2 when @last_chance == 0 @state = :dead end end end def can_move?(dx, dy, dr) x = @x + dx y = @y + dy angle = @angle + dr blocks(angle).each_with_index do |row, r| row.each_with_index do |col, c| if col == 1 then if x + c < 0 || x + c >= FIELD_COL || y + r >= FIELD_ROW || @field.matrix[y + r][x + c] != nil then return false end end end end true end end class Field attr_reader :matrix, :state def initialize(game, row, col) @game = game @matrix = Array.new(row){Array.new(col)} @state = :live @flash_counter = 0 end def import(tetrimino) tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| @matrix[tetrimino.y + r][tetrimino.x + c] = tetrimino.id if col == 1 end end end def flash_lines @flash_counter -= 1 case when @flash_counter < 0 @flash_counter = @game.fps / 2 @state = :flash when @flash_counter == 0 @state = :clear end @matrix.each do |row| row.map! {|i| i = @flash_counter % 2 + 7} if !row.include?(nil) end end def clear_lines @matrix.reject!{|row| !row.include?(nil)} deleted_line = FIELD_ROW - @matrix.size deleted_line.times{@matrix.unshift(Array.new(10){nil})} deleted_line end def freeze @state = :dead end end class Frame def initialize(screen) @screen = screen @field_view = Texture.new(FIELD_W, FIELD_H) @score_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @lines_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @next_view = Texture.new(FIELD_W, 2 * BLOCK_SIZE) @pause_overlay = Texture.new(WINDOW_W, WINDOW_H) @pause_overlay.fill(Color.new(0, 0, 0, 160)) @pause_overlay.draw_message("Pause") @gameover_overlay = Texture.new(WINDOW_W, WINDOW_H) @gameover_overlay.fill(Color.new(0, 0, 0, 160)) @gameover_overlay.draw_message("Game Over") end def update(sender) @field_view.fill(Color.new(0, 0, 0, 128)) @score_view.fill(Color.new(0, 0, 0, 128)) @lines_view.fill(Color.new(0, 0, 0, 128)) @next_view.fill(Color.new(255, 255, 255, 128)) @field_view.draw_tetrimino(sender.tetrimino) @field_view.draw_field(sender.field) @score_view.draw_number(sender.score_counter) @lines_view.draw_number(sender.lines_counter) @next_view.draw_tetrimino(sender.nextmino, 0, 1) @screen.fill(Color.new(255, 255, 255)) @screen.render_texture(@field_view, 1 * BLOCK_SIZE, 5 * BLOCK_SIZE) @screen.draw_text("SCORE", 13, 5) @screen.render_texture(@score_view, 13 * BLOCK_SIZE, 6 * BLOCK_SIZE) @screen.draw_text("LINES", 13, 9) @screen.render_texture(@lines_view, 13 * BLOCK_SIZE, 10 * BLOCK_SIZE) @screen.draw_text("NEXT", 4, 1) @screen.render_texture(@next_view , 1 * BLOCK_SIZE, 2 * BLOCK_SIZE) end def overlay(mode) case mode when :pause then @screen.render_texture(@pause_overlay , 0, 0) when :gameover then @screen.render_texture(@gameover_overlay , 0, 0) end end end class Dealer attr_reader :state, :field, :nextmino, :tetrimino, :score_counter, :lines_counter def initialize(game = @game) @game ||= game @state = :play @field = Field.new(game, FIELD_ROW, FIELD_COL) @nextmino = Tetrimino.new(game, @field) @tetrimino = Tetrimino.new(game, @field) @frame = Frame.new(game.screen) @score_counter = 0 @lines_counter = 0 end def update send(@state) end def play dx = 0 dy = 0.0625 dr = 0 @game.fps = 30 + @lines_counter / 2 delay_fps = @game.fps / 10 dx = 1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps / 3}).include?(:right) dx = -1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps / 3}).include?(:left) dy = 1 if Input.keys(:keyboard, {:duration =>-1, :delay => -1, :interval => 0}).include?(:down) dr = 1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps }).include?(:x) dr = 3 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps }).include?(:z) if @field.state == :dead then @state = :gameover return end @tetrimino.rotate(dr) @tetrimino.side_step(dx) @tetrimino.fall(dy) if @tetrimino.state == :dead then @field.import(@tetrimino) @field.flash_lines if @field.state == :clear then n = @field.clear_lines @score_counter += n**2 * 100 @lines_counter += n @field.freeze if @tetrimino.y <= 0 @tetrimino = @nextmino @nextmino = Tetrimino.new(@game, @field) end end @frame.update(self) end def pause @frame.update(self) @frame.overlay(:pause) end def gameover @frame.update(self) @frame.overlay(:gameover) end def reset initialize end def toggle_state case @state when :play then @state = :pause when :pause then @state = :play when :gameover then @state = :reset end end end Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| @dealer ||= Dealer.new(game) break if Input.keys(:keyboard).include?(:escape) @dealer.toggle_state if Input.keys(:keyboard, {:duration => 1, :delay => -1, :interval => 0}).include?(:space) @dealer.update end
コードの洗練とゲームの調整
- ここまで、当初の目的はひとまず達成できた。(まだ荒削りだけど)
- この後は、荒削りな状態をできる限り、洗練させていく作業である。
- コードの洗練
- コードの流れを見直す。
- 定数名・変数名・メソッド名・クラス名などの名称を見直す。
- 数ヶ月後の自分が見ても、見通しの良いコードを目指すのだ。
- また、さらなる改修が行いやすいコードでありたい。
- ゲームの調整
- 実際にこのテトリスで遊んで、違和感を感じた部分を修正する。
- 少しでもゲームとして楽しくなる方向に修正するのだ。
- わずかな操作感の改善であっても、その積み重ねによって、ゲームバランスが高まる。
- また遊びたいと思われるゲームになるよう、地道な改善を重ねていくのだ。
以上の方針で、思うままに修正を続けてみた。
フォントを定数に変更(diff)
- 秒間30回以上のフォントオブジェクトの生成に無駄を感じたので。
名称変更(diff)
- Fieldの状態と重複するため、Tetriminoの状態をfallingとlandedに変更
- toggleはオン・オフの切替をイメージするため、switch_stateに変更
パラメータ変更(diff)
- 落下速度がより早いタイミングで加速するようにするため
ブロックの回転方法を変更(diff)
- オブジェクトの生成を抑えてより高速に処理するため
カラーの取り扱いを定数に変更(diff)
- 秒間30回以上のカラーオブジェクトの生成に無駄を感じたので。
テトリミノを移動すると分身することがある不具合を修正(diff)
テトリミノを操作し続ければ、いつまでも動き続けるように変更(diff)
- 例えば、着地後もテトリミノを左右に操作し続ければ、いつまでも左右に動かし続けられる。
フィールド満杯付近でもテトリミノを操作できるように変更(diff)
ラストチャンスの時間を調整(diff)
テトリミノをフィールド上部から回転できるように修正(diff)
テトリミノの出現頻度が均等になるように変更(diff)
- 同じテトリミノが3回以上連続したり、欲しいテトリミノがほとんど出現しなかったり、という違和感を感じないようにしたい。
- 7種類のテトリミノの出現頻度が常に均等になるように乱数を生成する、uniq_randメソッドを追加してみた。
def uniq_rand(range) @@uniq_logs ||= [] begin id = Random.rand(range) end while @@uniq_logs.include?(id) @@uniq_logs << id @@uniq_logs = nil if @@uniq_logs.size >= range.size id end
- ちなみにrandより、Random.randの方がより自然なランダムを演出してくれるようだ。
コメント追加(diff)
スペースキーの操作をDealerに含める(diff)
- この修正によって、Game.runループはとってもシンプルになる。
Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| break if Input.keys(:keyboard).include?(:escape) @dealer ||= Dealer.new(game) @dealer.switch_state @dealer.update end
- そして、ゲーム全体を管理するDealer(ディーラー)クラスで、escキーを除くすべてのキー操作がコントロールされるのだ。
ここまでのコード
require "starruby" include StarRuby BLOCK_SIZE = 32 FIELD_ROW = 20 FIELD_COL = 10 FIELD_W = BLOCK_SIZE * FIELD_COL FIELD_H = BLOCK_SIZE * FIELD_ROW WINDOW_ROW = 26 WINDOW_COL = 18 WINDOW_W = BLOCK_SIZE * WINDOW_COL WINDOW_H = BLOCK_SIZE * WINDOW_ROW COLORS = [Color.new( 0, 255, 255), #0 Color.new(255, 255, 0), #1 Color.new( 0, 255, 0), #2 Color.new(255, 0, 0), #3 Color.new( 0, 0, 255), #4 Color.new(255, 128, 0), #5 Color.new(255, 0, 255), #6 Color.new(255, 255, 255), #7 Color.new(255, 255, 192)] #8 WHITE_COLOR = Color.new(255, 255, 255) WHITE_COLOR_128 = Color.new(255, 255, 255, 128) BLACK_COLOR = Color.new(0, 0, 0) BLACK_COLOR_128 = Color.new(0, 0, 0, 128) BLACK_COLOR_160 = Color.new(0, 0, 0, 160) FONT_24 = Font.new("/Library/Fonts/Arial Bold.ttf", 24) FONT_36 = Font.new("/Library/Fonts/Arial Bold.ttf", 36) # Star Rubyの描画可能な唯一のオブジェクト class Texture def draw_block(x, y, color) render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, color) end def draw_tetrimino(tetrimino, offset_x = 0, offset_y = 0) return if !tetrimino tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(tetrimino.x + c + offset_x , tetrimino.y + r + offset_y, COLORS[tetrimino.id]) if col == 1 end end end def draw_field(field) return if !field field.matrix.each_with_index do |row, r| row.each_with_index do |col, c| draw_block(c, r, COLORS[col]) if col != nil end end end def draw_text(str, col, row) render_text(str, col * BLOCK_SIZE, row * BLOCK_SIZE, FONT_24, BLACK_COLOR) end def draw_number(num) font_width, font_height = FONT_24.get_size(num.to_s) margin_width, margin_height = 5, 0 x = self.width - font_width - margin_width y = self.height - font_height - margin_height render_text(num.to_s, x, y, FONT_24, BLACK_COLOR) end def draw_message(str) font_width, font_height = FONT_36.get_size(str) x = (self.width - font_width ) / 2 y = (self.height - font_height) / 2 render_text(str, x, y, FONT_36, WHITE_COLOR) end end # Tetriminoは、ランダムな4つのブロックの組み合わせ # Tetriminoは、2つの状態を持つ(:falling, :landed) class Tetrimino attr_reader :id, :state, :x, :y @@minos = [] @@minos << [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]] @@minos << [[1,1], [1,1]] @@minos << [[0,1,1], [1,1,0], [0,0,0]] @@minos << [[1,1,0], [0,1,1], [0,0,0]] @@minos << [[1,0,0], [1,1,1], [0,0,0]] @@minos << [[0,0,1], [1,1,1], [0,0,0]] @@minos << [[0,1,0], [1,1,1], [0,0,0]] def initialize(game, field) @game = game @field = field @id = uniq_rand(0..6) @blocks = [@@minos[@id], #回転なし @@minos[@id].transpose.map(&:reverse), #右90度回転 @@minos[@id].reverse.map(&:reverse), #180度回転 @@minos[@id].transpose.reverse] #左90度回転(右270度回転) @x, @y, @angle = (4 - @blocks.size) / 2 + 3, -1.9, 0 @state = :falling @last_chance = 0 end def uniq_rand(range) @@uniq_logs ||= [] begin id = Random.rand(range) end while @@uniq_logs.include?(id) @@uniq_logs << id @@uniq_logs = nil if @@uniq_logs.size >= range.size id end def blocks(angle = @angle) @blocks[angle % 4] end def rotate(dr) return if dr == 0 if can_move?(0, 0, dr) then @angle += dr @last_chance = @game.fps * 0.5 end end def side_step(dx) return if dx == 0 if can_move?(dx, 0, 0) then @x += dx @last_chance = @game.fps * 0.5 end end def fall(dy) @y = @y.to_i if dy == 1 if can_move?(0, 1, 0) then @y += dy @last_chance = @game.fps * 0.5 else @last_chance -= 1 @state = :landed if @last_chance < 0 end end def can_move?(dx, dy, dr) x = @x + dx y = @y + dy angle = @angle + dr blocks(angle).each_with_index do |row, r| row.each_with_index do |col, c| if col == 1 then if x + c < 0 || x + c >= FIELD_COL || y + r >= FIELD_ROW || y + r >= 0 && @field.matrix[y + r][x + c] != nil then return false end end end end true end end # Fieldは、Tetriminoを積み上げるエリア # Fieldは、4つの状態を持つ(:live, :flash, :clear, :dead) class Field attr_reader :matrix, :state def initialize(game, row, col) @game = game @matrix = Array.new(row){Array.new(col)} @state = :live @flash_counter = 0 end def import(tetrimino) tetrimino.blocks.each_with_index do |row, r| row.each_with_index do |col, c| @matrix[tetrimino.y + r][tetrimino.x + c] = tetrimino.id if col == 1 end end end def flash_lines @flash_counter -= 1 case when @flash_counter < 0 @flash_counter = @game.fps / 2 @state = :flash when @flash_counter == 0 @state = :clear end @matrix.each do |row| row.map! {|i| i = @flash_counter % 2 + 7} if row.all? end end def clear_lines @matrix.reject!{|row| row.all?} deleted_line = FIELD_ROW - @matrix.size deleted_line.times{@matrix.unshift(Array.new(10){nil})} deleted_line end def freeze @state = :dead end end # Frameは、ゲーム画面を定義する class Frame def initialize(screen) @screen = screen @field_view = Texture.new(FIELD_W, FIELD_H) @score_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @lines_view = Texture.new(4 * BLOCK_SIZE, 1 * BLOCK_SIZE) @next_view = Texture.new(FIELD_W, 2 * BLOCK_SIZE) @pause_overlay = Texture.new(WINDOW_W, WINDOW_H) @pause_overlay.fill(BLACK_COLOR_160) @pause_overlay.draw_message("Pause") @gameover_overlay = Texture.new(WINDOW_W, WINDOW_H) @gameover_overlay.fill(BLACK_COLOR_160) @gameover_overlay.draw_message("Game Over") end def update(sender) @field_view.fill(BLACK_COLOR_128) @score_view.fill(BLACK_COLOR_128) @lines_view.fill(BLACK_COLOR_128) @next_view.fill(WHITE_COLOR_128) @field_view.draw_tetrimino(sender.tetrimino) @field_view.draw_field(sender.field) @score_view.draw_number(sender.score_counter) @lines_view.draw_number(sender.lines_counter) @next_view.draw_tetrimino(sender.nextmino, 0, 1.9) @screen.fill(WHITE_COLOR) @screen.render_texture(@field_view, 1 * BLOCK_SIZE, 5 * BLOCK_SIZE) @screen.draw_text("SCORE", 13, 5) @screen.render_texture(@score_view, 13 * BLOCK_SIZE, 6 * BLOCK_SIZE) @screen.draw_text("LINES", 13, 9) @screen.render_texture(@lines_view, 13 * BLOCK_SIZE, 10 * BLOCK_SIZE) @screen.draw_text("NEXT", 4, 1) @screen.render_texture(@next_view , 1 * BLOCK_SIZE, 2 * BLOCK_SIZE) end def overlay(mode) case mode when :pause then @screen.render_texture(@pause_overlay , 0, 0) when :gameover then @screen.render_texture(@gameover_overlay , 0, 0) end end end # Dealerは、ゲーム全体のコントローラー # Dealerは、4つの状態を持つ(:play, :pause, :gameover, :reset) class Dealer attr_reader :state, :field, :nextmino, :tetrimino, :score_counter, :lines_counter def initialize(game = @game) @game ||= game @state = :play @field = Field.new(game, FIELD_ROW, FIELD_COL) @nextmino = Tetrimino.new(game, @field) @tetrimino = Tetrimino.new(game, @field) @frame = Frame.new(game.screen) @score_counter = 0 @lines_counter = 0 end def update send(@state) end def play dx = 0 dy = 0.0625 dr = 0 @game.fps = 30 + @lines_counter delay_fps = @game.fps / 10 dx = 1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps / 3}).include?(:right) dx = -1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps / 3}).include?(:left) dy = 1 if Input.keys(:keyboard, {:duration =>-1, :delay => -1, :interval => 0}).include?(:down) dr = 1 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps }).include?(:x) dr = 3 if Input.keys(:keyboard, {:duration => 1, :delay => delay_fps, :interval => delay_fps }).include?(:z) if @field.state == :dead then @state = :gameover return end if @tetrimino.state == :falling then @tetrimino.rotate(dr) @tetrimino.side_step(dx) @tetrimino.fall(dy) end if @tetrimino.state == :landed then @field.import(@tetrimino) @field.flash_lines if @field.state == :clear then n = @field.clear_lines @score_counter += n**2 * 100 @lines_counter += n @field.freeze if @tetrimino.y < 0 @tetrimino = @nextmino @nextmino = Tetrimino.new(@game, @field) end end @frame.update(self) end def pause @frame.update(self) @frame.overlay(:pause) end def gameover @frame.update(self) @frame.overlay(:gameover) end def reset initialize end def switch_state if Input.keys(:keyboard, {:duration => 1, :delay => -1, :interval => 0}).include?(:space) then case @state when :play then @state = :pause when :pause then @state = :play when :gameover then @state = :reset end end end end Game.run(WINDOW_W, WINDOW_H, :title => "tetris") do |game| break if Input.keys(:keyboard).include?(:escape) @dealer ||= Dealer.new(game) @dealer.switch_state @dealer.update end
完。