DXRubyRPG Project

DXRubyでRPGを作る(2)

2013/12/18

DXRuby Advent Calendar18日目です。前回はRPGに何が必要なのかを考えました。今回も引き続き、DXRubyでRPGを作る方法を模索します。

では引き続き、どんな構造に?

Event(モジュール)

Eventは任意の名前での登録はないですが、情報を階層的に保持する点でDataと酷似します。

Eventは、まず発生源ごとにそれに適したフォームを作成することにしましょう。例えば、Mapの特定のマスに行ったときに起こすものなら、2つ情報を与えるようなフォームにします。このフォームのクラス名はEPageにします。

そして管理しやすいように、フォームは全て何らかのグループに属すようにし、そのグループはEventモジュール直下か他のグループに登録させましょう。このグループの名前はEBookにします。

その手順は、

  1. EventモジュールにEBookクラスを、EBookクラスにEPageクラスを作る
  2. EPageクラスのinitializeで、発生源の特定用の情報の数と、発生時に渡す情報の数を引数としてもらい、add_eventメソッドとholdメソッドを定義する
  3. EventモジュールとEBookクラスにbookメソッドを定義し、Symbolを渡すことでその名前のメソッドでEBookクラスを返すようにし、EBookクラスにはpageメソッドも定義し、SymbolとEPage.new用の引数をもらい、EPageクラスを返すようにする
  4. has_book?メソッドとhas_page?メソッドを定義する

こうなります。では早速「1.」をやりましょう。

module Event
  class EBook
    class EPage
    end
  end
end

こうですね。次に「2.」をやります。

module Event
  class EBook
    class EPage
      attr_reader :args
        #どんなEPageかを表します
        #具体的には、add_eventやhold時の引数の数です
      
      def initialize(arg_num,hold_args)
        #arg_numは発生源を特定するための情報の数
        #hold_argsはhold時に送信する情報量(arg_numを除く)の数値の配列
        #  ([最低限, 最大数])
        
        @event = {} #イベント保存用ハッシュ
        hold_arg_num = hold_args.map{|i| (i < 0 ? 0 : i.div(1))}
          #hold_argsの中身に不正なものが無いかの修正
        @args = [arg_num, hold_arg_num.min, hold_arg_num.max]
          #これを見ればどんなEPageか特定できます
        
        tmp = class << self;self;end #特異クラスを生成
        tmp.class_eval do
          #まずholdを定義します
          #その際、hold_arg_numの値に応じてエラーメッセージを変えます
          #holdメソッド内で毎回条件分岐しないようにしています
          
          if hold_arg_num.min != hold_arg_num.max
            define_method(:hold){|*args|
              if args.size < arg_num + hold_arg_num.min || args.size > arg_num + hold_arg_num.max
                raise(ArgumentError,
                  "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min}..#{arg_num + hold_arg_num.max})",
                  caller(1))
              end
              if args.size - arg_num == 0
                @event[args].call if @event.has_key?(args)
              else
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              end
            }
          else
            if hold_arg_num.min == 0
              define_method(:hold){|*args|
                if args.size != arg_num
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num})",
                    caller(1))
                end
                @event[args].call if @event.has_key?(args)
              }
            else
              define_method(:hold){|*args|
                if args.size != arg_num + hold_arg_num.min
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min})",
                    caller(1))
                end
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              }
            end
          end
          
          #次にadd_eventメソッドを定義する
          
          define_method(:add_event){|*args, &block|
            if args.size != arg_num
              raise(ArgumentError,
                "wrong number of argument (#{args.size} for #{arg_num})",
                caller(1))
            end
            if block
              @event[args] = block
            else
              @event.delete(args) if @event.has_key?(args)
            end
          }
        end
      end
    end
  end
end

私はこうなりました。一気に難しく見えるようになりました。が、「特異クラス」とdefine_method(name){..}さえ理解すればいけると思います。

特異クラスを生成したtmp = class << self;self;endという一行ですが、;で複数行を1行にまとめただけです。元々は、

class << self
  self
end

という特異クラスをtmpに代入したのです。簡単に言うと、selfのコピーです。そしてdefine_method(name){..}は、渡したブロックをそのままnameの名前でメソッドとして定義するものです。

まとめると、selfのコピーに対してclass_eval{..}をし、そのブロック内でdefine_method(name){..}を実行することで、selfの特異メソッドとして登録しています。

これができたらもう簡単です。同じ要領で「3.」をやります。

module Event
  def self.book(name)
    newR = EBook.new
    define_method(name){newR}
    module_function name
    newR
  end
  
  class EBook
    def page(name,arg_num,hold_arg_num = [0])
      newF = EPage.new(arg_num,hold_arg_num)
      tmp = class << self;self;end
      tmp.class_eval do
        define_method(name){newF}
      end
      newF
    end
    
    def book(name)
      newR = EBook.new
      tmp = class << self;self;end
      tmp.class_eval do
        define_method(name){newR}
      end
      newR
    end
    
    class EPage
      attr_reader :args
      
      def initialize(arg_num,hold_args)
        @event = {}
        hold_arg_num = hold_args.map{|i| (i < 0 ? 0 : i.div(1))}
        @args = [arg_num, hold_arg_num.min, hold_arg_num.max]
        
        tmp = class << self;self;end
        tmp.class_eval do
          if hold_arg_num.min != hold_arg_num.max
            define_method(:hold){|*args|
              if args.size < arg_num + hold_arg_num.min || args.size > arg_num + hold_arg_num.max
                raise(ArgumentError,
                  "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min}..#{arg_num + hold_arg_num.max})",
                  caller(1))
              end
              if args.size - arg_num == 0
                @event[args].call if @event.has_key?(args)
              else
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              end
            }
          else
            if hold_arg_num.min == 0
              define_method(:hold){|*args|
                if args.size != arg_num
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num})",
                    caller(1))
                end
                @event[args].call if @event.has_key?(args)
              }
            else
              define_method(:hold){|*args|
                if args.size != arg_num + hold_arg_num.min
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min})",
                    caller(1))
                end
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              }
            end
          end
          
          define_method(:add_event){|*args, &block|
            if args.size != arg_num
              raise(ArgumentError,
                "wrong number of argument (#{args.size} for #{arg_num})",
                caller(1))
            end
            if block
              @event[args] = block
            else
              @event.delete(args) if @event.has_key?(args)
            end
          }
        end
      end
    end
  end
end

さっきより簡単です。補足すると、module_function(name)はnameの名前を持つメソッドをモジュールメソッドにしています。

さいごに、「4.」を付け足します。

module Event
  def self.book(name)
    newR = EBook.new
    define_method(name){newR}
    module_function name
    newR
  end
  
  def self.has_book?(name)
    return false unless self.singleton_methods.include?(name)
    return true if self.__send__(name).class == EBook
    return false
  end
  
  class EBook
    def page(name,arg_num,hold_arg_num = [0])
      newF = EPage.new(arg_num,hold_arg_num)
      tmp = class << self;self;end
      tmp.class_eval do
        define_method(name){newF}
      end
      newF
    end
    
    def book(name)
      newR = EBook.new
      tmp = class << self;self;end
      tmp.class_eval do
        define_method(name){newR}
      end
      newR
    end
    
    def has_book?(name)
      return false unless self.singleton_methods.include?(name)
      return true if self.__send__(name).class == EBook
      return false
    end
    
    def has_page?(name)
      return false unless self.singleton_methods.include?(name)
      return true if self.__send__(name).class == EPage
      return false
    end
    
    class EPage
      attr_reader :args
      
      def initialize(arg_num,hold_args)
        @event = {}
        hold_arg_num = hold_args.map{|i| (i < 0 ? 0 : i.div(1))}
        @args = [arg_num, hold_arg_num.min, hold_arg_num.max]
        
        tmp = class << self;self;end
        tmp.class_eval do
          if hold_arg_num.min != hold_arg_num.max
            define_method(:hold){|*args|
              if args.size < arg_num + hold_arg_num.min || args.size > arg_num + hold_arg_num.max
                raise(ArgumentError,
                  "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min}..#{arg_num + hold_arg_num.max})",
                  caller(1))
              end
              if args.size - arg_num == 0
                @event[args].call if @event.has_key?(args)
              else
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              end
            }
          else
            if hold_arg_num.min == 0
              define_method(:hold){|*args|
                if args.size != arg_num
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num})",
                    caller(1))
                end
                @event[args].call if @event.has_key?(args)
              }
            else
              define_method(:hold){|*args|
                if args.size != arg_num + hold_arg_num.min
                  raise(ArgumentError,
                    "wrong number of argument (#{args.size} for #{arg_num + hold_arg_num.min})",
                    caller(1))
                end
                if @event.has_key?(args[0...arg_num])
                  @event[args[0...arg_num]].call(*args[arg_num...args.size])
                end
              }
            end
          end
          
          define_method(:add_event){|*args, &block|
            if args.size != arg_num
              raise(ArgumentError,
                "wrong number of argument (#{args.size} for #{arg_num})",
                caller(1))
            end
            if block
              @event[args] = block
            else
              @event.delete(args) if @event.has_key?(args)
            end
          }
        end
      end
    end
  end
end

singleton_methodsはレシーバの特異メソッドの名前(Symbol)の配列です。そのなかに含まれているかを見ればいいのです。

これで完成です。リファレンスもどきと使い方の例を載せておきます。

  <Eventモジュールの構造>
  Event
    |
    +―EBook ‥‥EPageの入れ物
    |    |
    |    +―EBook ‥‥EBook内にEBookを入れられる
    |    |    |
    |    |    ・
    |    |    ・
    |    |    ・
    |    |
    |    +―EPage ‥‥イベントフォーム。
    |
    +―EBook
    |
    +―EBook
    ・
    ・
    ・

  <リファレンス>
  Event
    .book(name)
      nameの名前でEventモジュールにメソッドを作成し、
      新しく生成したEBookオブジェクトを返す。

      [PARAM] name:
        定義するメソッド名をStringかSymbolで指定する。

    .has_book?(name)
      nameの名前でEventモジュールにEBookが登録されているかを返す。

      [PARAM] name:
        調べる名前をSymbolで指定する。

  EBook
    #book(name)
      nameの名前でselfにメソッドを作成し、新しく生成したEBookオブジェクトを返す。
      Eventモジュールの同名メソッドと同じ動作。

      [PARAM] name:
        定義するメソッド名をStringかSymbolで指定する。

    #page(name, arg_num, hold_arg_num)
      nameの名前でselfにメソッドを作成し、
      arg_num,hold_arg_numを渡して新しく生成したEPageオブジェクトを返す。

      [PARAM] name:
        定義するメソッド名をStringかSymbolで指定する。

      [PARAM] arg_num:
        イベントフォームで、
        イベント発生源を指定するために必要な情報量を数値で指定する。
        EPage#add_event, #holdの引数の数に影響する。

      [PARAM] hold_arg_num:
        イベントフォームで、
        イベント発生時に送信する情報量(arg_numを除く)を数値の配列で指定する。
        EPage#add_event, #holdの引数の数に影響する。

      [SEE_ALSO] EPage#hold, EPage#add_event

    #has_book?(name)
      nameの名前で自身にEBookが登録されているかを返す。

      [PARAM] name:
        調べる名前をSymbolで指定する。

    #has_page?(name)
      nameの名前で自身にEPageが登録されているかを返す。

      [PARAM] name:
        調べる名前をSymbolで指定する。

  EPage
    #add_event(arg1[, arg2[, arg3 ...]]){|[hold_arg1[, hold_arg2 ...]]| ..}
      情報とイベント(ブロック)を関連付ける。
      ブロックの引数には、hold時に送信された情報がそのまま渡される。
      このブロックの引数の個数に対してエラーチェックをしないが、
      適切な個数でないとhold時にエラーが発生することに注意。[[trap:Event]]

      [PARAM] arg1[, arg2[, arg3 ...]]:
        EBook#page時に指定したarg_num個の、
        イベント発生源を指定するために必要な情報を指定する。

      [SEE_ALSO] EPage#hold, EBook#add_event

    #hold(arg1[, arg2[, arg3 ...]][, hold_arg1[, hold_arg2 ...]])
      情報に関連付けられたイベントを発生させる。

      [PARAM] arg1[, arg2[, arg3 ...]]:
        EBook#page時に指定したarg_num個の、
        イベント発生源を指定するために必要な情報を指定する。

      [PARAM] [arg1[, arg2 ...]]:
        EBook#page時に指定したhold_arg_numの中の
        (最小の数)個以上(最大の数)個以下の、イベントに送る情報を指定する。

      [SEE_ALSO] EPage#add_event, EBook#page

  <使い方>
    Event.book(:group).page(:test, 1, [0,1])
    Event.group.test.add_event(7){|str = "何も送られません"|
      print "#{str}でした。\n"
    }
    Event.group.test.hold(6) #=> nothing will happen
    Event.group.test.hold(7, "私は愚か") #=> 私は愚かでした。
    #Event.group.test.hold() #=> ArgumentError

Scene(モジュール)

Sceneの条件分岐はMap/Battle/Animationそれぞれのモジュール/クラスの方で.update/.drawを定義しておけば簡単です。ついでに.init/.quitなども呼びましょう。今回はシンプルにコードだけ貼っておきます。

module Scene
  @@scene = nil
  
  def self.scene
    @@scene
  end
  
  def self.scene=(v)
    @@scene = v
  end
  
  def self.update
    if (@@scene.methods + @@scene.private_methods).include?(:count_frame)
      @@scene.__send__(:count_frame)
    end
    
    old_scene = @@scene
    
    if (@@scene.methods + @@scene.private_methods).include?(:update)
      @@scene.__send__(:update)
    end
    
    if @@scene != old_scene
      if (old_scene.methods + old_scene.private_methods).include?(:quit)
        old_scene.__send__(:quit)
      end
      if (@@scene.methods + @@scene.private_methods).include?(:init)
        @@scene.__senf__(:init)
      end
    end
    
    old_scene = @@scene
    
    if (@@scene.methods + @@scene.private_methods).include?(:draw)
      @@scene.__send__(:draw)
    end
    
    if @@scene != old_scene
      if (old_scene.methods + old_scene.private_methods).include?(:quit)
        old_scene.__send__(:quit)
      end
      if (@@scene.methods + @@scene.private_methods).include?(:init)
        @@scene.__send__(:init)
      end
    end
  end
end

EventScene(クラス)

この部分はフレーム毎に呼び出すだけで、自由な演出ができるようにします。こちらから何かすると単なる制限になってしまう気がしてなりません。こちらもコードのみです。

class EScene
  attr_reader :update_block, :draw_block, :frame_counter
  
  def initialize(&block)
    init
    @update_block = nil
    @draw_block = block
  end
  
  def set_update(&block)
    @update_block = block
  end
  
  def set_draw(&block)
    @draw_block = block
  end
  
  def update
    self.instance_exec(self.frame_counter, &@update_block) if @update_block
  end
  
  def draw
    self.instance_exec(self.frame_counter, &@draw_block) if @draw_block
  end
  
  def init
    @frame_counter = 0
  end
  
  def quit
  end
  
  private
  def step
    @frame_counter += 1
  end
end

Battle(モジュール)

まだ考えられていません。RPGと言っても戦闘だけは何種類もあるので、その中で最低限の共通部分を模索しています。

Map(モジュール)

最後に持ってきたところからもうお分かりでしょうが、今回の山場です。

Mapは圧倒的に情報量が多いのです。それでも最終的にはゲームループに持ち込むのですが、

  • Menuが出ているときは他の部分は停止させる
  • Key入力に応じて主人公が移動する
  • Main Characterと被らないようにNPCを動かす
  • Main Characterが移動中ならスムーズにScrollする
  • MenuのMenuに情報を伝える

など考慮しなければならない難問が多くあります。それに、マップチップ情報やNPCの数はもはや不特定多数です。

しかし!そんな不特定多数のオブジェクトの制御をするのに参考になるものを、DXRubyの作者mirichi氏が作っています。DXRubyWSです。具体的には、オブジェクトツリーで上位にあるオブジェクトから順々に情報を伝えています。

DXRubyのマニュアルの3.9 Spriteクラスをご覧ください。次のような説明があります。

Spriteクラスはゲームのキャラを扱う場合の基本となるクラスです。描画位置、描画パラメータ、画像データを保持し、自分自身を描画する機能を持ちます。

実はこれ、よくよく注意して読むと面白いことが分かります。Sprite.update(ary)とあるのにSprite#updateはないのです。これは、Spriteが「継承」され、#updateが定義されることを前提に作られているからです。

そしてDXRubyWSもそれを利用しています。WS(=Window System)を構成するあらゆるコントロールはこれを継承したWSControlまたはそのサブクラスなのです。そしてそれらを制御する際には、大きなものから小さなものへと情報が伝えられていきます。

これを応用するとどうなるでしょうか。ずばり、例えばMenuはMenuクラスで、NPCはNPCクラスでそれに特化したオブジェクトを生成し、Map.update時に順に#updateが呼び出されるようにするのです。

この時、Menuが出ていたら、他のオブジェクトの#updateをしなければいいのです。

Key入力の情報を主人公の#update時に処理すればいいのです。

NPCの#updateの時に主人公の位置を調べればいいのです。

Map.update時に主人公の移動中かを調べればいいのです。

Menuオブジェクトは状況に応じてその配下のMenuオブジェクトの#updateを呼び出せば良いのです。

このように、それぞれの#update時に少し条件分岐をするだけで、全体では複雑な制御が可能になります。

コードを載せられなくてすみません。現在、この辺りを製作中です。詳しいことはまたこまごまとした記事になるでしょう。

終わりに

私は、私と同じようにRPGを作りたくなった人たちが再利用できるような、汎用なライブラリを作ろうとしています。先に話した制御構造を実現し、データを設定すれば基本的には使えるようにしたいのです。

それができれば、元々がプログラミング言語なので、RPGツクールのRGSSのようなものを作らなくても拡張性は最大です。すばらしいRPGが作られることでしょう。

現在、Data管理モジュール・Event管理モジュールが出来てはいます。Mapモジュールも少しずつ作っています。詳しくはダウンロードをご覧ください。長い時間がかかると思いますが、学生が終わるまでには作り上げたいと思います。

最後まで読んで頂きありがとうございました。