テトリスの作り方

前回からの続き。

前回までにStar Rubyでスカッシュゲームを作ったのだけど、たった60行のシンプルなゲームであった。ほとんど全ての処理を、Game.runブロックの中に書いていた。
今度はもうちょっと本格的なゲームを作ってみたい。そうだ、テトリスを作ろう!テトリスはサンプルコードの中に含まれているのだけど、敢えて自分の頭で考えて独自の実装をしてみるのだ。落ちものパズルゲームの元祖。果たしてちゃんとできるだろうか?
前回のスカッシュのように、すべての処理をGame.runブロックの中に書いてしまうと、長過ぎるループに惑わされて、たぶん破綻しそう。部品ごとにどのように処理を分担するべきか、テトリスワールドに踏み込んで考えてみた。

作るテトリスの仕様

  • 4つの正方形を組み合わせたブロック(テトリミノと呼ばれる)が、上から落下してくる。
    • テトリミノには7つの形状がある。

テトリス - Wikipedia
  • テトリミノを回転、移動させながら、積み上げていく。
    • テトリミノは最下部、または他のテトリミノの上まで来て、それ以上落下できなくなると、その位置に固定される。
    • テトリミノが固定されると、新たなテトリミノが上から落ちてくる。
  • ブロックが隙間なく1段すべてに積み上がると、その1段はクリアされる。
    • テトリミノの形状によって、2段、3段、あるいは4段同時にクリアされることもある。
    • クリアされた位置より上のブロックは、クリアされた段数だけ落下する。
  • テトリミノが一番上まで積み上がってしまったら、ゲームオーバー。
    • 次々と落下してくるテトリミノを隙間なく積み上げて、より多くの段をクリアすることが目的。

落ちるブロック(GitHub差分

  • サンプルコードのhelloworld.rbを以下のように修正すると、ブロックが落下するコードとなる。
require "starruby"
include StarRuby

white = Color.new(255, 255, 255)

y = 0
x = 8
Game.run(320, 240, :title => "tetris") do |game|
  break if Input.keys(:keyboard).include?(:escape)
  x += 8 if Input.keys(:keyboard).include?(:right)
  x -= 8 if Input.keys(:keyboard).include?(:left)

  y += 1
  y = 0 if y > 240

  game.screen.clear
  game.screen.render_rect(x, y, 16, 16, white)
end
  • 「game.screen.render_rect(x, y, 16, 16, white)」によって、ブロックを描画している。
    • (x,y)座標に、幅16px、高さ16px、白塗りの四角を描くのだ。
  • 正方形のブロックが1つ、繰り返し落下する。

テトリスワールドの定義(GitHub差分

テトリス - Wikipediaによると...

  • ブロックを積み上げるエリアは、幅10ブロック、高さ20ブロックのサイズである。
  • よって、その定義に従って、Game.runウィンドウを以下のように定義してみた。
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

white = Color.new(255, 255, 255)

y = 0
x = 8
Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  break if Input.keys(:keyboard).include?(:escape)
  x += 8 if Input.keys(:keyboard).include?(:right)
  x -= 8 if Input.keys(:keyboard).include?(:left)

  y += 1
  y = 0 if y > 240

  game.screen.clear
  game.screen.render_rect(x, y, 16, 16, white)
end

ブロック単位の処理に変更(GitHub差分

  • テトリスワールドでは、基本的にブロック単位で処理することにする。
  • 但し、滑らかに落下して欲しいので、落下する時はピクセル単位の移動にしておく。
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

white = Color.new(255, 255, 255)

y = 0
x = 3
Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  break if Input.keys(:keyboard).include?(:escape)
  x += 1 if Input.keys(:keyboard).include?(:right)
  x -= 1 if Input.keys(:keyboard).include?(:left)

  y += 0.125
  y = 0 if y >= FIELD_ROW

  game.screen.clear
  game.screen.render_rect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE, white)
end

テトリミノを描画する(GitHub差分

  • いつまでもブロックの落下だけでは進歩がない...。
  • この辺でテトリスらしく、4つのブロックを組み合わせたテトリミノを描画してみたい。
  • テトリミノに関することは、Tetriminoクラスで処理することにする。
  • Tetriminoクラスを追加すると、今までのコードが劇的に変化する。
  • まだ完全でないけど、暫定的に動くコードは以下のようになった。
  • テトリミノのデータは、2次元配列に保存した。
  • いずれ回転させる時のことを考えて、対称性を保つため正方行列となるようにした。
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



class Tetrimino
  attr_reader :blocks, :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
    @id = rand(0..6)
    @blocks = @@minos[@id]
    @x, @y = 3, 0
  end
  
  def side_step(dx)
    @x += dx
  end
  
  def fall(dy)
    @y += dy
  end
  
end



def draw_block(game, x, y)
  game.screen.render_rect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE, Color.new(255, 255, 255))
end

def draw_tetrimino(game, tetrimino)
  return if !tetrimino
  tetrimino.blocks.each_with_index do |row, r|
    row.each_with_index do |col, c|
      draw_block(game, tetrimino.x + c , tetrimino.y + r) if col == 1
    end
  end
end

Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @tetrimino ||= Tetrimino.new
  dx = 0
  dy = 0.125
  
  break if Input.keys(:keyboard).include?(:escape)
  dx =  1 if Input.keys(:keyboard).include?(:right)
  dx = -1 if Input.keys(:keyboard).include?(:left)

  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)
  @tetrimino = nil if @tetrimino.y >= FIELD_ROW

  game.screen.clear
  draw_tetrimino(game, @tetrimino)
end

描画はTextureクラスに任せる(GitHub差分

  • 描画について、1つ発見があった!
  • コード末尾の「draw_tetrimino(game, @tetrimino)」は、書いた時から格好悪いなと思っていた。
  • 本当は「game.screen.draw_tetrimino(@tetrimino)」と、書きたいのだ。

その方法が分かった!

  • game.screen.class とは StarRuby::Texture なので、Textureクラスを拡張すればいいのだ!
  • ついでに、個々のブロックの存在が見えるように、1px小さい正方形のブロックを描くようにした。
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



class Texture
  def draw_block(x, y)
    render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(255, 255, 255))
  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) if col == 1
      end
    end
  end

end

class Tetrimino
  attr_reader :blocks, :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
    @id = rand(0..6)
    @blocks = @@minos[@id]
    @x, @y = 3, 0
  end
  
  def side_step(dx)
    @x += dx
  end
  
  def fall(dy)
    @y += dy
  end
  
end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @tetrimino ||= Tetrimino.new
  dx = 0
  dy = 0.125
  
  break if Input.keys(:keyboard).include?(:escape)
  dx =  1 if Input.keys(:keyboard).include?(:right)
  dx = -1 if Input.keys(:keyboard).include?(:left)

  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)
  @tetrimino = nil if @tetrimino.y >= FIELD_ROW

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
end

テトリミノを回転させる(GitHub差分

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



class Texture
  def draw_block(x, y)
    render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(255, 255, 255))
  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) if col == 1
      end
    end
  end

end

class Tetrimino
  attr_reader :blocks, :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
    @id = rand(0..6)
    @blocks = @@minos[@id]
    @x, @y, @angle = 3, 0, 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)
    @angle += dr
  end
  
  def side_step(dx)
    @x += dx
  end
  
  def fall(dy)
    @y += dy
  end
  
end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @tetrimino ||= Tetrimino.new
  dx = 0
  dy = 0.125
  dr = 0
  
  break if Input.keys(:keyboard).include?(:escape)
  dx =  1 if Input.keys(:keyboard).include?(:right)
  dx = -1 if Input.keys(:keyboard).include?(:left)
  dr =  1 if Input.keys(:keyboard).include?(:x)
  dr =  3 if Input.keys(:keyboard).include?(:z)

  @tetrimino.rotate(dr)
  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)
  @tetrimino = nil if @tetrimino.y >= FIELD_ROW

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
end

テトリミノの移動を制限する(GitHub差分

  • テトリミノを制限なく動かせてしまう状況を改善した。
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



class Texture
  def draw_block(x, y)
    render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(255, 255, 255))
  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) if col == 1
      end
    end
  end

end

class Tetrimino
  attr_reader :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
    @id = rand(0..6)
    @blocks = @@minos[@id]
    @x, @y, @angle = 3, 0, 0
    @state = :live
  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)
    if can_move?(0, 1, 0) then
      @y += dy
    else
      @state = :dead
    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 then
            return false
          end
        end
      end
    end
    true
  end
  
end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @tetrimino ||= Tetrimino.new
  dx = 0
  dy = 0.125
  dr = 0
  
  break if Input.keys(:keyboard).include?(:escape)
  dx =  1 if Input.keys(:keyboard).include?(:right)
  dx = -1 if Input.keys(:keyboard).include?(:left)
  dr =  1 if Input.keys(:keyboard).include?(:x)
  dr =  3 if Input.keys(:keyboard).include?(:z)

  @tetrimino.rotate(dr)
  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)
  @tetrimino = nil if @tetrimino.state == :dead

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
end

テトリミノを積み上げる(GitHub差分

  • 現状、テトリミノは一番下まで落ちると、消えてしまう...。
  • しかし、テトリスワールドでは、テトリミノは積み上がらなくてはならない。
  • そこで、テトリミノを積み上げる「場」として、Fieldクラスを組み込んでみた。
  • Fieldクラスは10×20の二次元配列を持っていて、
  • テトリミノが落下できなくなると、その時のテトリミノを取り込むのだ。
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



class Texture
  def draw_block(x, y)
    render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(255, 255, 255))
  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) 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) if col != nil
      end
    end
  end

end

class Tetrimino
  attr_reader :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(field)
    @field = field
    @id = rand(0..6)
    @blocks = @@minos[@id]
    @x, @y, @angle = 3, 0, 0
    @state = :live
  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)
    if can_move?(0, 1, 0) then
      @y += dy
    else
      @state = :dead
    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)}
  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] = 1 if col == 1
      end
    end
  end
end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @field ||= Field.new(FIELD_ROW, FIELD_COL)
  @tetrimino ||= Tetrimino.new(@field)
  dx = 0
  dy = 0.125
  dr = 0
  
  break if Input.keys(:keyboard).include?(:escape)
  dx =  1 if Input.keys(:keyboard).include?(:right)
  dx = -1 if Input.keys(:keyboard).include?(:left)
  dr =  1 if Input.keys(:keyboard).include?(:x)
  dr =  3 if Input.keys(:keyboard).include?(:z)

  @tetrimino.rotate(dr)
  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)

  if @tetrimino.state == :dead then
    @field.import(@tetrimino)
    @tetrimino = nil
  end

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
  game.screen.draw_field(@field)
end

敏感過ぎるキー反応を鈍くする(GitHub差分

  • 90度回転したいのに180度回転しているし、1ブロック移動したいのにあっという間に2、3ブロック移動してしまう。
  • キーの反応が敏感過ぎるのだ!
  • 何も設定していないと、現状の:fps = 30で、1秒間に30回も押されているキーの値を読み取ってしまうのだ。
  • その解決方法は、StarRuby::Inputに書かれていた。

Input.keys(device, options = {})

...中略...

options には Hash を指定します。指定できるキーと値は、以下の通りです。

キー デフォルト値
:device_number 0番から始まるデバイス番号。ゲームパッドの場合のみ有効です。 0
:duration キーを押し始めてから、キーが押されていると判別される持続時間 (フレーム数)。 -1 を指定した場合は無限です (押しっぱなしの間ずっと「押された」と判別されます)。 -1
:delay :duration を正数で指定している場合、 2 回目以降「押された」と判別されるまでの遅延時間 (フレーム数)。 -1 を指定した場合は無限です (2 回目以降の判定がありません)。 -1
:interval 2 回目以降「押された」と判断される時間間隔 (フレーム数)。 0
http://www.starruby.info/ja/documentation/api_reference/star_ruby.input
  • よって、修正箇所はGame.runブロックの以下のコード。
  • ついでに、↓矢印キーで素早く落下するようにしてみた。
      • 修正箇所が限定的なので差分のみ


(master)$ git diff
diff --git a/tetris.rb b/tetris.rb
index 2f50a39..a724e19 100644
--- a/tetris.rb
+++ b/tetris.rb
@@ -148,10 +148,11 @@ Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
dr = 0

break if Input.keys(:keyboard).include?(:escape)
- dx = 1 if Input.keys(:keyboard).include?(:right)
- dx = -1 if Input.keys(:keyboard).include?(:left)
- dr = 1 if Input.keys(:keyboard).include?(:x)
- dr = 3 if Input.keys(:keyboard).include?(:z)
+ 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)

@tetrimino.rotate(dr)
@tetrimino.side_step(dx)

ブロックで満たされた段をクリアする(GitHub差分

  • ブロックで満たされた段は、クリアする必要がある。
      • 修正箇所が限定的なので差分のみ


(master)$ git diff
diff --git a/tetris.rb b/tetris.rb
index a724e19..04dc0c3 100644
--- a/tetris.rb
+++ b/tetris.rb
@@ -136,6 +136,13 @@ class Field
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})}
+ end
+
end


@@ -160,6 +167,7 @@ Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|

if @tetrimino.state == :dead then
@field.import(@tetrimino)
+ @field.clear_lines
@tetrimino = nil
end

ラストチャンスを与える(GitHub差分

  • テトリミノが着地して、即動かせなくなると、何だか悲しい...。
  • その辺の事情は本家テトリスも察していて、着地しても若干の間、回転や移動ができるようになっている。
  • ならば自分のテトリスにも、そのチャンスを与えてみたい。
  • Tetriminoクラスに@last_chanceというカウンターを追加して、カウンター数が残っている間は操作できるようにしてみた。
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



class Texture
  def draw_block(x, y)
    render_rect(x * BLOCK_SIZE + 1, y * BLOCK_SIZE + 1, BLOCK_SIZE - 1, BLOCK_SIZE - 1, Color.new(255, 255, 255))
  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) 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) if col != nil
      end
    end
  end

end

class Tetrimino
  attr_reader :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
    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)}
  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] = 1 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})}
  end

end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @field ||= Field.new(FIELD_ROW, FIELD_COL)
  @tetrimino ||= Tetrimino.new(game, @field)
  dx = 0
  dy = 0.125
  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)

  @tetrimino.rotate(dr)
  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)

  if @tetrimino.state == :dead then
    @field.import(@tetrimino)
    @field.clear_lines
    @tetrimino = nil
  end

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
  game.screen.draw_field(@field)
end

ブロックが潰れる現象の対策(GitHub差分

  • ラストチャンスを与えたら、着地した時にブロックが潰れる現象が気になってきた。
  • この現象は、↓矢印キーで素早く落下させた時に発生する。
  • よって、↓矢印キーを押した時は、端数のy座標を整数に丸める処理を追加してみた。
      • 修正箇所が限定的なので差分のみ


(master)$ git diff
diff --git a/tetris.rb b/tetris.rb
index 86ce962..27cc8b8 100644
--- a/tetris.rb
+++ b/tetris.rb
@@ -97,6 +97,7 @@ class Tetrimino

def fall(dy)
@last_chance -= 1
+ @y = @y.to_i if dy == 1
if can_move?(0, 1, 0) then
@y += dy
else

色付きのテトリミノにしておく(GitHub差分

  • 白黒のテトリスは、ちょっと地味である。
  • やはり、華やかなカラー表示にしたい。
  • テトリミノの色はガイドラインに従ってみた。
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
RGBS = [[  0, 255, 255],
        [255, 255,   0],
        [  0, 255,   0],
        [255,   0,   0],
        [  0,   0, 255],
        [255, 127,   0],
        [255,   0, 255]]



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
    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

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)}
  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})}
  end

end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @field ||= Field.new(FIELD_ROW, FIELD_COL)
  @tetrimino ||= Tetrimino.new(game, @field)
  dx = 0
  dy = 0.125
  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)

  @tetrimino.rotate(dr)
  @tetrimino.side_step(dx)
  @tetrimino.fall(dy)

  if @tetrimino.state == :dead then
    @field.import(@tetrimino)
    @field.clear_lines
    @tetrimino = nil
  end

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
  game.screen.draw_field(@field)
end

ゲームオーバーを追加する(GitHub差分

  • 一番上まで積み上がっても、永遠にテトリミノを描き続けてしまう状況を何とかしたい。
  • ゲームオーバーの演出をする必要がある。
  • ついでに、落下速度も少し遅くしてみた。
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
RGBS = [[  0, 255, 255],
        [255, 255,   0],
        [  0, 255,   0],
        [255,   0,   0],
        [  0,   0, 255],
        [255, 127,   0],
        [255,   0, 255]]



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

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})}
  end

  def freeze
    @state = :dead
  end

end



Game.run(FIELD_W, FIELD_H, :title => "tetris") do |game|
  @field ||= Field.new(FIELD_ROW, FIELD_COL)
  @tetrimino ||= Tetrimino.new(game, @field)
  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)
    @field.clear_lines
    @field.freeze if @tetrimino.y <= 0
    @tetrimino = nil
  end

  game.screen.clear
  game.screen.draw_tetrimino(@tetrimino)
  game.screen.draw_field(@field)
end


これでひとまず、最小限のテトリスの出来上がり。

  • コードの構成
コード区分 仕事内容 種類
requireとinclude Star Rubyの機能を取り込む
定数定義 コード全体で有効になる定数の設定
Textureクラス定義 game.screenへの描画処理を担当
Tetriminoクラス定義 テトリミノの操作とその状態を保存 モデル的な役割
Fieldクラス定義 ブロックが積み上がる領域の状態を保存 モデル的な役割
Game.runループ キー入力に対応する処理の依頼、描画処理の依頼 コントローラー的な役割
  • 行列の回転
    • Tetriminoクラス blocksメソッド
  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
  • テトリミノの移動可能かどうかの判定
    • Tetriminoクラス can_move?メソッド
  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