2Dアクションゲームの当たり判定と当たらない判定

iPhoneで2Dアクションゲーム


CEDEC 2011にiPhoneで2Dアクションゲームを出展予定。
それの練習ということで2Dアクションゲームの当たり判定について学ぶなど。
実はまだ審査中なのでこの努力は水泡に帰すかもしれないけれど、人生に無駄なことなどないと信じよう。

プロトタイプアプリ「1-2」



何かに似てるけど他人の空似。
配管工のおっさんがレンガや文字の書かれたブロックが配置されたステージで縦横無尽に飛び回る。
開発予定のゲームはiPhoneの加速度センサーを利用した「重力の操作」が肝になっているのでその部分も実装。デバイスを反転させると重力も反転しておっさんが落下する。

UIDeviceOrientationLandscapeRight(ホームボタンが右側に来る向き)の時はいつも通り地面に立っている。

iPhoneをひっくり返すと・・・

天井が地面に、地面が天井に。

ビームも出るよ!

ちなみに画面右下に表示されている半透明の円はジャンプボタン(赤色)とアタックボタン(黄色)です。

ジャンプの実装


なめらかなジャンプを実装することは2Dアクションゲームを作る上で必須。以前「iPhoneで横スクロールアクションゲームを作りたい」でジャンプゲームを実装した時は、キャラクターに垂直方向の速度と加速度を持たせて計算していました。これでも間違いではないのですが、ジャンプゲームの元祖「マリオ」のジャンプはVerlet積分という計算法で実装しているようなので、今回はこちらで実装してみました。
こちらのブログを参考にさせて頂きました。「Gemmaの日記 マリオのジャンプ実装法とVerlet積分

当たり判定


やっぱり2Dアクションゲームを作る上でまず苦労するのはフィールドにあるオブジェクトとの当たり判定です。
フィールドに斜面などを作ってしまうとかなりの高等技術が必要になる(に違いない!)ので、まずはフィールドを16x16ピクセル単位で区切って、ブロック(レンガやハテナブロック)の有無を二次元配列で確保しました。

// フィールドデータ
extern int gField[kFieldHeight][kFieldWidth];

厳密にはブロックがない場所は0に、ブロックがある場所は1以上の数字にしてあり、ブロックの種類毎に数字を変えて描画するときに役立てています。

おっさんを左に移動するメソッドmoveLeftの中では、おっさんの左側のブロックとの当たり判定を行います。おっさんの位置データはfloat型なので、これをフィールド情報を格納した二次元配列のインデックスに変換します。

  // 左方向のブロックとの当たり判定
  int fieldX = floor(self.x / kBlockWidth);
  int fieldY = floor((self.y+self.height/2) / kBlockHeight);
  if (gField[fieldY][fieldX]) {
    self.x = kBlockWidth * fieldX + kBlockWidth;
    [self stopRunning];
    return;
  }

おっさんを垂直方向に移動するメソッドmoveVertical(ずっとmoveVerticallyの方が良かった気がしている)の中では、上方向に速度がある時はおっさんの上側のブロックとの、下方向に速度がある時はおっさんの下側のブロックとの当たり判定を行います。

  // 半身の当たり判定をするために、はみ出している部分を計算し(protrusion)、
  // はみ出している側に合わせて補正値|offset|を計算する。
  double protrusion = (self.x+self.width/2.0) - kBlockWidth * floor((self.x+self.width/2.0) / kBlockWidth);
  int offset = 0;
  if (protrusion <= (0.3*kBlockWidth)) {
    offset = -1;
  } else if (protrusion >= (0.7*kBlockWidth)) {
    offset = 1;
  }
  
  // 上方向に速度があるならば、上方向のブロックとの当たり判定
  // 下方向に速度があるならば、下方向のブロックとの当たり判定
  if (self.y - prevOrigin_.y < 0) {
    int fieldX = floor((self.x+self.width/2.0) / kBlockWidth);
    int fieldY = floor(self.y / kBlockHeight);
    if (gField[fieldY][fieldX] || gField[fieldY][fieldX+offset]) {
      self.y = fieldY * kBlockHeight + kBlockHeight;
      if (status_.isUpsideDown) {
        status_.isJumping = NO;
      }
    }
  } else if (self.y - prevOrigin_.y > 0) {
    int fieldX = floor((self.x+self.width/2.0) / kBlockWidth);
    int fieldY = floor((self.y+self.height) / kBlockHeight);
    if (gField[fieldY][fieldX] || gField[fieldY][fieldX+offset]) {
      self.y = fieldY * kBlockHeight - kBlockHeight;
      if (!status_.isUpsideDown) {
        status_.isJumping = NO;
      }
    }    
  }

大事なのはコードの最初の方で準備している「半身の当たり判定」です。
そもそも仕様ではおっさんは半身でブロックに立つことができます。

ですが、半身の当たり判定をしない、すなわち自分の真下(真上)のブロックとしか当たり判定をしないと、ちょっと足が掛かっているだけではすり抜けてしまいます。

しっかり2ブロック分の当たり判定をしておくことですり抜けを防止することができます。

当たらない判定


もう1つ大事なのは「当たらない判定」です。
ジャンプしていない時でも常に重力は受けているので、ブロックの上で足を踏み外したらおっさんは落下しなければなりません。
そうしないとこんなファンタスティックスなことに・・・

このような空中歩行をさせないために「当たらない判定」が必要なのです。
moveVerticalの中ではジャンプ中でない時は当たらない判定をして足下にブロックがない場合は落下状態に移行します(実装の上では落下状態とジャンプ状態は区別していません)。

  // ジャンプ中ではない場合は当たらない判定をする
  // 上下さかさまなら上方向のブロックと当たらない判定
  // そうでないなら下方向のブロックと当たらない判定
  if (!status_.isJumping) {
    if (status_.isUpsideDown) {
      int fieldX = floor((self.x+self.width/2.0) / kBlockWidth);
      int fieldY = floor(self.y / kBlockHeight);
      if (gField[fieldY][fieldX] == 0) {
        prevOrigin_ = self.origin;        
        status_.isJumping = YES;
      }
    } else {
      int fieldX = floor((self.x+self.width/2.0) / kBlockWidth);
      int fieldY = floor((self.y+self.height) / kBlockHeight);
      if (gField[fieldY][fieldX] == 0) {
        prevOrigin_ = self.origin;        
        status_.isJumping = YES;
      }
    }
  }

「当たってない判定」の方がいいかな、と思ったりした。