テトリスの仕上げ

前回からの続き。

テトリスとしての基本機能は実装できた。テトリスが動作するサンプルコードとしては十分なのだけど、一般的なゲームとしてはリリースできるレベルではない。最低限以下の機能が不足している。

  • 得点や次のブロックを表示する機能も欲しい。
  • ゲーム中の一時停止と再開の機能も欲しい。
  • ゲームオーバー後は終了するしかなかったが、終了せずにゲームをやり直す機能も欲しい。

以上の機能は、サンプルコードの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
オーバーレイを追加(diff
  • オーバーレイとは、以下のように画面全体を覆うような効果のこと。


状態分岐を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回以上のカラーオブジェクトの生成に無駄を感じたので。
!row.include?(nil)をrow.all?に変更(diff
  • allというメソッドの存在を知って、嬉しくなった。
テトリミノを移動すると分身することがある不具合を修正(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

完。