rubyでライフゲームを実装してみた

きのうと今日は趣味のプログラミング。
高校一年の時にライフゲームの存在を知った。その頃使っていたコンピュータはNECのTK-80BS。早速NEC LEVEL1 BASICを使って実装にチャレンジした。このBASICは整数演算のみで配列変数も1次元のものが1つ(@)しか使えず、すごく不自由なプログラミング。それでもなんとか完成させ実行してみると、32文字x16行で1世代進めるのに一分以上かかってしまう。描画も非常に遅く、ちょっとパターンを試してみるだけでも日が暮れてしまう。そこで8080マシン語でプログラムを組み直し実行してみた。すると1秒間に3世代程度更新するようになって、何とか遊べるようになった。(本題からそれるけど、TK-80BSではハードウェアの問題からVRAMにデータを書き込むとノイズが発生する。機械語で高速に書き込むと吹雪が降っているような画面になってしまう)
その後PC-8001を入手したのでZ80マシン語で実装し直し、78文字x23行で1秒に3世代程度のスピードが確保できた。このプログラムは、当時所属していたFORESIGHTの会報に載せて頂いた。(「開発言語」が「BASIC」となっているけど、DATA文のマシン語プログラムをPOKEでメモリに書き込む形だった...と思う)
その後FM-7PC-9801などを所有するようになったけど、ライフゲームを実装することは無かった。
久しぶりにライフゲームを実装する気になったのは、当時と比べてハードウェアが格段に進歩して比較にならないような速度となっており、ソフトウェア面でもプログラマにとって優しい(== ハードウェア性能が要求される)rubyなどの動的言語が台頭してきているという状況で、当時ハンドアセンブルで苦労して実装してやっと実現できた性能が、今時のハードウェア・ソフトウェアで作るとどうなのよ....を実感してみたかったということ。
プログラム本体(lifegame.rb)のソースは、このエントリの最後に載せた。このソースだけではダメで、画面サイズや初期パターンを定義するファイルが別途必要となる。
最初にTK-80BSの画面環境で試してみた。画面サイズは32x16だけど、上下左右に枠を1文字確保していたので、30x14となる。初期パターン定義は以下の通り。

require 'lifegame.rb'

LifeGame.setup :size_h => 30,   # 水平方向サイズ
               :size_v => 14,   # 垂直方向サイズ
               :count => 1000   # 実行回数
#
# 初期パターン
#
LifeGame.set_pattern(<<EOS)
    ooooooooo
 o       ooooo
EOS

LifeGame.start

MacbookCore2Duo 2.4GHz 4GB OSX 10.5.6)のターミナルで実行してみた結果は以下の通り。

速い!! fpsを表示出来るようにしているのだけど、最大120fps程度出ている模様。TK-80BSマシン語の40倍の性能が出ている。

次にPC-8001での画面サイズで試してみた。

require 'lifegame.rb'

LifeGame.setup :size_h => 78,   # 水平方向サイズ
               :size_v => 23,   # 垂直方向サイズ
               :count => 1000   # 実行回数
#
# 初期パターン
#
LifeGame.set_pattern(<<EOS)
    ooooooooo
 o       ooooo
EOS

LifeGame.start


25fps程度の性能。PC-8001よりも10倍ほど速い。

調子に乗ってターミナルを最大化して試してみた。

require 'lifegame.rb'

LifeGame.setup :size_h => 300,   # 水平方向サイズ
               :size_v => 80,   # 垂直方向サイズ
               :count => 1000   # 実行回数
#
# 初期パターン
#
LifeGame.set_pattern(<<EOS)
    ooooooooo
 o       ooooo
EOS

LifeGame.start


さすがに遅い.最大1.8fps程度。プログラムを見直せば改善できると思う。

最後に、速すぎる場合、最大fpsを制限する指定を追加してみた。5fpsで表示させてみる。

require 'lifegame.rb'

LifeGame.setup :size_h => 30,   # 水平方向サイズ
               :size_v => 14,   # 垂直方向サイズ
               :count => 1000,  # 実行回数
               :max_fps  => 5     # 最大FPS
# 
# 初期パターン
#
LifeGame.set_pattern(<<EOS)
    ooooooooo
 o       ooooo
EOS

LifeGame.start


かなり見やすくなった。

本体のプログラムは、以下の通り。

class Field
  attr_reader :data

  def initialize(h_size,v_size)
    @h_size,@v_size = h_size,v_size
    @data = Array.new(@v_size,0)
  end

  def [](h,v) ; @data[v][h] end

  def []=(h,v,value)
    case value
    when 0 then @data[v] &= ~(1 << h)
    when 1 then @data[v] |= 1 << h
    else raise
    end
  end

  def size ; [ @h_size,@v_size ] end

  def clear ; @data.fill(0) end

  def advance(target_field)
    population = 0
    (0...@v_size).each do |v_pos|
      (0...@h_size).each do |h_pos|

        surround_count = 0
        (-1).upto(1) do |v_def|
          (-1).upto(1) do |h_def|
            next if v_def == 0 && h_def == 0
            v_point = v_pos + v_def
            h_point = h_pos + h_def
            if (v_point >= 0 && v_point < @v_size) then
              surround_count += @data[v_point][h_point]
            end
          end
        end

        population += 
          target_field[h_pos,v_pos] = apply_rule(surround_count,@data[v_pos][h_pos])
      end
    end
    return population
  end

  def apply_rule(surround_count,source_status)
    case surround_count
    when 2 then source_status
    when 3 then 1
    else 0
    end
  end
end


class Deployer
  attr_reader :field

  def initialize(h_size,v_size) 
    @field = Field.new(h_size,v_size)
  end

  class Array
    def each_with_index(&block)
      index = 0
      self.each do |value|
        yield value,index
        index += 1
      end
      self
    end
  end 

  def set_pattern(pattern)
    fieldsize_h,fieldsize_v = *@field.size

    pattern_data = pattern.split "\n"
    h_base = (fieldsize_h - pattern_data.map { |data| data.size}.max) / 2
    v_base = (fieldsize_v - pattern_data.size) / 2
    pattern_data.each_with_index do |row_data,row_index|
      (row_data.split '').each_with_index do |ch,ch_index|
        @field[h_base+ch_index,v_base+row_index] = 1 if ch != ' '
      end 
    end
  end
end

class Life
  def initialize(field,count,fps)
    @field,@count,@fps = field,count,fps
    @h_size,@v_size = *@field.size
    @field2 = Field.new(*@field.size)
  end

  def start
    population = 0
    message = ''
    now_time = (previous_time = Time.now.to_f) + 1
    1.upto(@count) do |generation|
      display(' ','o',generation,population,message)
      effective_fps = 1.0/(now_time - previous_time);
      if effective_fps > @fps then
        sleep 1.0/@fps - (now_time - previous_time)
        now_time = Time.now.to_f
      end
      message = "fps=#{'%5.2f' % (1/(now_time - previous_time))}"
      population = @field.advance(@field2)    
      previous_time,now_time = now_time,Time.now.to_f

      @field,@field2 = @field2,@field
    end
  end

  def display(dead_char,live_char,generation,population,message)
    cls
    locate 1,1
    print "Generation=#{generation}  Population=#{population} #{message}"
    (0...@v_size).each do |v_index|
      contents = ' '*@h_size
      (0...@h_size).each do |h_index|
        contents[h_index] = [dead_char,live_char][@field[h_index,v_index]]
      end
      locate(1,v_index+3)
      print(contents)
    end
    $stdout.flush
  end

  private
  def locate(h,v) ; print "\033[#{v};#{h}H"; end
  def cls ; print "\033[2J" ; end

end

# for game definition

class LifeGame
  def LifeGame.setup(info)
      @@dep = Deployer.new(info[:size_h],info[:size_v])
      @@count = info[:count]
      @@fps = info[:max_fps] || 300
  end
  def LifeGame.set_pattern(pattern) ; @@dep.set_pattern(pattern) ; end
  def LifeGame.start ; Life.new(@@dep.field,@@count,@@fps).start ; end
end 

  • 【2009/05/24追記】

Array#each_with_indexを再定義していた。取り除いたコードを再掲。

class Field
  attr_reader :data

  def initialize(h_size,v_size)
    @h_size,@v_size = h_size,v_size
    @data = Array.new(@v_size,0)
  end

  def [](h,v) ; @data[v][h] end

  def []=(h,v,value)
    case value
    when 0 then @data[v] &= ~(1 << h)
    when 1 then @data[v] |= 1 << h
    else raise
    end
  end

  def size ; [ @h_size,@v_size ] end

  def clear ; @data.fill(0) end

  def advance(target_field)
    population = 0
    (0...@v_size).each do |v_pos|
      (0...@h_size).each do |h_pos|

        surround_count = 0
        (-1).upto(1) do |v_def|
          (-1).upto(1) do |h_def|
            next if v_def == 0 && h_def == 0
            v_point = v_pos + v_def
            h_point = h_pos + h_def
            if (v_point >= 0 && v_point < @v_size) then
              surround_count += @data[v_point][h_point]
            end
          end
        end

        population += 
          target_field[h_pos,v_pos] = apply_rule(surround_count,@data[v_pos][h_pos])
      end
    end
    return population
  end

  def apply_rule(surround_count,source_status)
    case surround_count
    when 2 then source_status
    when 3 then 1
    else 0
    end
  end
end


class Deployer
  attr_reader :field

  def initialize(h_size,v_size) 
    @field = Field.new(h_size,v_size)
  end

  def set_pattern(pattern)
    fieldsize_h,fieldsize_v = *@field.size

    pattern_data = pattern.split "\n"
    h_base = (fieldsize_h - pattern_data.map { |data| data.size}.max) / 2
    v_base = (fieldsize_v - pattern_data.size) / 2
    pattern_data.each_with_index do |row_data,row_index|
      (row_data.split '').each_with_index do |ch,ch_index|
        @field[h_base+ch_index,v_base+row_index] = 1 if ch != ' '
      end 
    end
  end
end

class Life
  def initialize(field,count,fps)
    @field,@count,@fps = field,count,fps
    @h_size,@v_size = *@field.size
    @field2 = Field.new(*@field.size)
  end

  def start
    population = 0
    message = ''
    now_time = (previous_time = Time.now.to_f) + 1
    1.upto(@count) do |generation|
      display(' ','o',generation,population,message)
      effective_fps = 1.0/(now_time - previous_time);
      if effective_fps > @fps then
        sleep 1.0/@fps - (now_time - previous_time)
        now_time = Time.now.to_f
      end
      message = "fps=#{'%5.2f' % (1/(now_time - previous_time))}"
      population = @field.advance(@field2)    
      previous_time,now_time = now_time,Time.now.to_f

      @field,@field2 = @field2,@field
    end
  end

  def display(dead_char,live_char,generation,population,message)
    cls
    locate 1,1
    print "Generation=#{generation}  Population=#{population} #{message}"
    (0...@v_size).each do |v_index|
      contents = ' '*@h_size
      (0...@h_size).each do |h_index|
        contents[h_index] = [dead_char,live_char][@field[h_index,v_index]]
      end
      locate(1,v_index+3)
      print(contents)
    end
    $stdout.flush
  end

  private
  def locate(h,v) ; print "\033[#{v};#{h}H"; end
  def cls ; print "\033[2J" ; end

end

# for game definition

class LifeGame
  def LifeGame.setup(info)
      @@dep = Deployer.new(info[:size_h],info[:size_v])
      @@count = info[:count]
      @@fps = info[:max_fps] || 300
  end
  def LifeGame.set_pattern(pattern) ; @@dep.set_pattern(pattern) ; end
  def LifeGame.start ; Life.new(@@dep.field,@@count,@@fps).start ; end
end