せらぴんブログ

サークル「せらぴん」のうのはな透です。やっぱり眼鏡っ娘が好き!!

DxRuby::Spriteの思想を信じよ

www.adventar.org

Ruby Game Developing Advent Calendar 2016 6日目の記事です。

昨日はvivit_jcさんの「DXRubyで作ったシューティングゲームを交えて高校生向けプログラミング講座をしてきた話」でした。
実際あのプログラムを一晩で書いたって聞かされたときは「は?なんだよ天才かよ」って思いました。さすがvivitさんやね!
あと1~2日目の記事を見て「司エンジンむっちゃ面白そうやん……」ってなったので、来年あたり使ってみたい。

本日は拙作「パズルっぽいゲーム」を制作した時の、DxRubyのSpriteの設計に大変助けられたよ、というお話をします。

7年越しの落ちゲー2度目の挑戦

実は「落ちゲー*1を作ろう!」と思ったのは今回が初めてではありません。
思い返せば7年ほど前。自分のPCとVisualStudio、そしてDXライブラリを手に入れて無敵感に舞い上がった私がまず作ろうと思ったのが
「ポーカーゲーム*2」と「落ちゲー」でした。

そして旧版と「パズルっぽいゲーム」を比較した時、より短期間でよりグレードの高いものになっています。
一体どうしてでしょう?

Spriteの仕様にゲームを“合わせる”

それは私のプログラミング能力が向上したから!!
――で済ませてもいいのですが、実際はそうでもないです。

今回「パズルっぽいゲーム」の制作では、(結果論ではありますが)DxRuby::Spriteの以下の3つの特徴にトコトン乗っかる形で設計していきました。

  • Spriteは継承してナンボ
  • 更新したいものはすべてSprite.update()に突っ込む
  • 描写したいものはすべてSprite.draw()に突っ込む

これが功を奏したのでは、と思うんですよね。
しかしこれ、言葉にすればチョー簡単ですよね?

実際に見てみましょう。下記がPuzzleField.rb(一部抜粋)。ゲームの肝となるクラスです。

# パズルフィールドクラス
class PuzzleField
  extend Forwardable
  def_delegators :@fight, :attack

  # パズル中のメインループ処理
  def process
    # アニメーション実行中なら消去/移動は行わない
    unless animating?
      processes = [Proc.new{ fall }, Proc.new{ erase }, Proc.new{ raising }, Proc.new{ move_player }]
      processes.each do |pr|
        # trueが返ってきた(何らかの処理が実行された)ら、そこで打ち止め
        break if pr.call
      end
    end
    update
  end
  
  # 描写のみを行い、内部の更新を一切行わない
  # ポーズ時などに使用
  def draw
    Sprite.draw([puzzle_objects, @field_images, @chain_counter, @score, @level, @damage_counter, @back_image])
  end
  
  # 各種更新処理&描写処理
  def update
    Sprite.update([puzzle_objects, @sounds.values, @erase_sounds, @score, @level, @damage_counter, @cube_factory])
    Sprite.clean(@cubes)
    draw
  end
  
  # キューブとプレイヤーの合算
  def puzzle_objects
    [@cubes, @player].flatten
  end

注目すべきはupdateメソッドとdrawメソッド。
ここに突っ込んでるのはすべてSpriteを継承した各種画像オブジェクトです。
updateで座標やらを更新して、drawで書き出す。もうチョー簡単! めんどくさいこと一切なし!
めんどくさいことはprocessメソッドの中にある fall(), erase(), raising(), move_player() の四天王が一括して管理してます。

旧版ではどうやってた?

ちなみに旧版でどうやってたかというと、例えば描写が以下の通り*3

void cField::view(){
  ClearDrawScreen();
  int color;
  for( int i=1; i<FIELD_WIDTH-1; i++ ){
    for( int j=1; j<FIELD_HEIGHT; j++ ){
      switch( field[i][j] ){
        case FLARE:
          color = GetColor( 255, 80, 80 );
          break;
        case AQUA:
          color = GetColor( 40, 200, 255 );
          break;
        case GAIA:
          color = GetColor( 40, 160, 40 );
          break;
        case GUST:
          color = GetColor( 220, 220, 220 );
          break;
        case STELLAR:
          color = GetColor( 255, 250, 100 );
          break;
        case METAL:
          color = GetColor( 40, 40, 60 );
          break;
        case NEU:
          color = GetColor( 40, 0, 40 );
          break;
        default:
          color = GetColor( 0, 0, 0 );
      }
      DrawString( i*20,j*20,"●",color);
    }
  }
  DrawString( cursor.x*20,cursor.y*20,"★",GetColor( 255, 255, 255 ));
  DrawLine( 15, 276, 160, 276, GetColor( 255, 255, 255 ) );
  DrawString( 300, 20, "ESCで終了・SHIFTで段上げ", 0xffffff );
  ScreenFlip();
}

わぁすごい! 神クラスによる一括描写だ!!
ちなみに新版ではupdateまでですべての作業を終わらせて、drawはSpriteのメソッドを呼んでいるだけなので、ある意味0行です。

座標情報はオブジェクトの中に持った方がいいんじゃない?

あとrubyに関係ない設計の話ですが、旧版と新版で大きく違うなぁと思う場所がもう一点。

旧版は、ブロックや自機がどこにいるかという座標情報をfield二次元配列の順序という形で持っている。
新版は、座標情報をブロックや自機の基底クラスPuzzleObject内にPosオブジェクトとして持っている。

どちらがいいかと聞かれたら、そりゃ後者じゃないのかなって。

「配列の位置関係」という形で座標を持つと、ブロックが移動するたび配列の要素の交換が発生します。
この手の落ちゲーは何につけてもブロックや自機が動くし落下するし競り上がるしで、交換の手間が馬鹿になりません。

それより何より、添字として持つことで「Posをオブジェクトとして扱えない」という欠点もあります*4

Posに雑にメソッドを突っ込んでいくのはいいぞー、楽しいぞー。

# 座標を表すクラス
class Pos
  attr_accessor :x, :y
  
  def ==(o)
    @x == o.x && @y == o.y
  end
  
  def eql?(o)
    self == o
  end
  
  def +(o)
    Pos.new(@x+o.x, @y+o.y)
  end
  
  # 座標が指定する範囲内にあれば真を返す
  def between?(x_range, y_range)
    x_range.include?(@x) && y_range.include?(@y)
  end
  
  # 座標を指定する範囲内に収める
  def clamp(x_range, y_range)
    @x = @x.clamp(x_range)
    @y = @y.clamp(y_range)
  end
  
  # チェックすべき範囲のPos群を得る
  # ただしcheckedに含まれるものは除く
  def check_positions(checked = Set.new)
    result = Set.new(Pos.directions.map{|p| self + p})
    result.select! {|pos| pos.between?(0..FIELD_WIDTH-1, 0..FIELD_HEIGHT-1)}
    result - checked
  end
  
  # 1歩分進むための配列を返す
  def self.directions
    [Pos.new(0,1), Pos.new(-1,0), Pos.new(0,-1), Pos.new(1,0)]
  end
end

その他こまごまとしたこと

Sprite継承推奨法則を応用して、
Ayameを継承したMusicクラスを作り、
さらにMusicが同時に鳴らないようにJukeboxクラスを作って制御してたりします。

とまぁ、こんな感じです。
「パズルっぽいゲーム」はゲームをDLすれば雑にコードが確認できるから*5
皆もDLして教師にするなり反面教師にするなりしてくれよな!!


明日は……あれ?! 明日誰もいないぞ!?
まぁでもきっと誰かが颯爽と現れて良記事書いてくれるでしょう。
そう期待してますんで! 頼むよ! お楽しみに!!

*1:「パズルっぽいゲーム」が所謂“落ちゲー”に分類されるかは正直微妙。

*2:つまりうのはなポーカーも6年越し2度目の挑戦だったわけだ!!

*3:色の名前が厨臭いの許して……ホント勘弁して……。

*4:RubyならPosオブジェクトをキーとしたハッシュにするって手もあるけど、流石にそれをするならオブジェクトの内に持とうよ……と思う。

*5:暗号化なんて概念はなかった