DXライブラリとC言語でゲーム作成。今回は3Dゲームで重要なマップとの当たり判定の実装について。
3Dマップとの当たり判定の基本
どのジャンルにせよ3Dゲームなら大事な処理となる3Dマップとの当たり判定処理。処理の流れは基本以下のようになる。
- 3Dモデル(プレイヤーなど)から一定距離内にある3Dマップのポリゴンを検出
- 検出したポリゴンを壁と床に分ける
- 検出した壁ポリゴンと3Dモデルが衝突しているか走査。衝突していれば壁から押し出す
- 検出した床ポリゴンと3Dモデルが衝突しているか走査。衝突していれば床に着地しているとして床から押し出す(ジャンプ中の場合は天井と衝突していると判断し同様に天井から押し出す)。そうでなければ空中にいると判断
- 最後に3Dモデルの座標を更新する
全くの情報なしでやると1つの難所となるが親切にもDXライブラリ置き場のサンプルコードで基本となる処理を書いてくれているのでそれを踏襲する。敵やNPCとの当たり判定の実装は別記事にて紹介予定。
実装
それでは実装。今回はかなり長いコードになるので小分けで書いていく。
マップとの当たり判定処理についてだがプレイヤー以外にも敵やNPCなども同じ処理をする予定なのでまとめるという意味でMap.cpp/hにマップとの当たり判定処理を書いていく。
まずMap.cpp/hに今回必要となる変数を定義したりファイルをインクルードする。
Map.cpp
//以下をインクルード
#include "DxLib.h"
#include "Map.h"
#include "Player.h"
#include <math.h>
//以下の変数を定義
static int MapHandle; //マップハンドル
const static int PLAYER_MAX_HITCOLL = 1024; //処理するコリジョンポリゴンの最大数
const static float PLAYER_ENUM_DEFAULT_SIZE = 5.0f; //周囲のポリゴン検出に使用するサイズ(プレイヤー用)
const static float PLAYER_HIT_WIDTH = 0.5f; //当たり判定カプセルの半径
const static int HIT_TRYNUM = 16; //壁押し出し処理の最大試行回数
const static float HIT_SLIDE_LENGTH = 0.1f; //一度の壁押し出し処理でスライドさせる距離
//マップの初期化
void Map_Initialize() {
//マップの読み込み
MapHandle = MV1LoadModel("(読み込みたい3Dマップのモデルのパス)");
}
//マップの更新
void Map_Update() {
}
//マップの描画
void Map_Draw() {
MV1DrawModel(MapHandle);
}
//マップの終了処理
void Map_Finalize() {
MV1DeleteModel(MapHandle);
}
プレイヤーの情報が必要になるのでPlayer.hを、途中の計算で絶対値を使うので<math.h>をインクルードする。PLAYER_MAX_HITCOLLは小さい数にしないこと(128とかにすると壁をすり抜けたり突然落下したりする可能性あり)。
また大ジャンプ小ジャンプの実装で定義したジャンプ状態関連の列挙体をMap.cppでも使えるようにPlayer.hに移動して以下のように変更する。
Player.h
#ifndef DEF_PLAYER_H
#define DEF_PLAYER_H
//プレイヤーなどのジャンプ関連の列挙体
typedef enum {
NO_JUMP, //ジャンプしていない
JUMP_HIGH, //大ジャンプ
JUMP_LOW, //小ジャンプ
FALL //落下中
}Object_Jump;
extern Object_Jump object_jump;
//中略
次に今回のメイン処理となる関数 Map_CheckCollision() を書く。
Map.cpp
//以下の関数を追加
//マップとの当たり判定
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
//中身は後述
}
Map.h
//以下の関数を追加 void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower);
当たり判定処理を行う場合以下の5つが必要になるので引数を指定する。
- プレイヤーなどの3Dモデルの座標
- 当たり判定用の座標2つ
- 移動ベクトル
- ジャンプ状態
- ジャンプ力
3Dモデルの当たり判定用の座標となるPolyPos1, PolyPos2は前者が3Dモデルの座標で後者はPolyPos1から一定値高い場所にある座標(3Dモデルの頭にあるイメージ)。
そしてメイン処理。まず必要になる変数を定義し、3Dモデルの周辺にある3Dマップのポリゴンを検出する。
Map.cpp
/*****1*****/
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
//以下の変数や処理を追加
int i, j, k; // 汎用カウンタ変数
bool MoveFlag; // 水平方向に移動したかどうかのフラグ( false:移動していない true:移動した )
bool HitFlag; // ポリゴンに当たったかどうかを記憶しておくのに使う変数( 0:当たっていない 1:当たった )
MV1_COLL_RESULT_POLY_DIM HitDim; // プレイヤーの周囲にあるポリゴンを検出した結果が代入される当たり判定結果構造体
int KabeNum; // 壁ポリゴンと判断されたポリゴンの数
int YukaNum; // 床ポリゴンと判断されたポリゴンの数
MV1_COLL_RESULT_POLY* Kabe[PLAYER_MAX_HITCOLL]; // 壁ポリゴンと判断されたポリゴンの構造体のアドレスを保存しておくためのポインタ配列
MV1_COLL_RESULT_POLY* Yuka[PLAYER_MAX_HITCOLL]; // 床ポリゴンと判断されたポリゴンの構造体のアドレスを保存しておくためのポインタ配列
MV1_COLL_RESULT_POLY* Poly; // ポリゴンの構造体にアクセスするために使用するポインタ( 使わなくても済ませられますがプログラムが長くなるので・・・ )
HITRESULT_LINE LineRes; // 線分とポリゴンとの当たり判定の結果を代入する構造体
VECTOR PrevPos;
VECTOR NowPos;
char PrevJumpStatus = 0;
float PolyOffset;
//移動前の座標を保存
PrevPos = *Position;
//Poly1とPoly2のY座標の差を保存
PolyOffset = PolyPos2.y - PolyPos1.y;
//ジャンプ状態を保存
PrevJumpStatus = *JumpStatus;
// 移動後の座標を算出
NowPos = VAdd(PrevPos, MoveVec);
// x軸かy軸方向に 0.01f 以上移動した場合は「移動した」フラグを1にする
if (fabs(MoveVec.x) > 0.01f || fabs(MoveVec.z) > 0.01f)
{
MoveFlag = true;
}
else
{
MoveFlag = false;
}
// プレイヤーの周囲にあるステージポリゴンを取得する
// ( 検出する範囲は移動距離も考慮する )
HitDim = MV1CollCheck_Sphere(MapHandle, -1, *Position, PLAYER_ENUM_DEFAULT_SIZE + VSize(MoveVec));
/*****2に続く*****/
}
3Dマップのポリゴン検出の前に処理前の座標やジャンプ状態などを保存。そして MV1CollCheck_Sphere() で周囲のポリゴンを検出する。
続けて検出したポリゴンを壁と床に分ける。今回はポリゴンの法線ベクトルのY成分で分けている(45度ぐらいまでの坂を床と判定)。
/*****2*****/
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
/*****1の続き*****/
// 検出されたポリゴンが壁ポリゴン( XZ平面に垂直なポリゴン )か床ポリゴン( XZ平面に垂直ではないポリゴン )かを判断する
{
// 壁ポリゴンと床ポリゴンの数を初期化する
KabeNum = 0;
YukaNum = 0;
// 検出されたポリゴンの数だけ繰り返し
for (i = 0; i < HitDim.HitNum; i++)
{
// XZ平面に垂直かどうかはポリゴンの法線のY成分の値で判断する
if (HitDim.Dim[i].Normal.y < 0.7f && HitDim.Dim[i].Normal.y > -0.7f)
{
// 壁ポリゴンと判断された場合でも、プレイヤーのY座標+0.3fより高いポリゴンのみ当たり判定を行う
if (HitDim.Dim[i].Position[0].y > PrevPos.y + 0.3f ||
HitDim.Dim[i].Position[1].y > PrevPos.y + 0.3f ||
HitDim.Dim[i].Position[2].y > PrevPos.y + 0.3f)
{
// ポリゴンの数が列挙できる限界数に達していなかったらポリゴンを配列に追加
if (KabeNum < PLAYER_MAX_HITCOLL)
{
// ポリゴンの構造体のアドレスを壁ポリゴンポインタ配列に保存する
Kabe[KabeNum] = &HitDim.Dim[i];
// 壁ポリゴンの数を加算する
KabeNum++;
}
}
}
else
{
// ポリゴンの数が列挙できる限界数に達していなかったらポリゴンを配列に追加
if (YukaNum < PLAYER_MAX_HITCOLL)
{
// ポリゴンの構造体のアドレスを床ポリゴンポインタ配列に保存する
Yuka[YukaNum] = &HitDim.Dim[i];
// 床ポリゴンの数を加算する
YukaNum++;
}
}
}
}
/*****3に続く*****/
}
壁ポリゴンと検出した場合でもプレイヤーのY座標より一定以上高いところにあるもののみ取り扱うのがポイント。これがないと崖から落ちることができなくなるので。
検出したポリゴンを壁と床に分けたら次は壁と衝突しているか走査する。衝突した場合、衝突しなくなるまでか HIT_TRYNUM 回分壁の法線ベクトルに HIT_SLIDE_LENGTH 分移動させ続ける。
/*****3*****/
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
/*****2の続き*****/
if (KabeNum != 0)
{
// 壁に当たったかどうかのフラグは初期状態では「当たっていない」にしておく
HitFlag = false;
// 移動したかどうかで処理を分岐
if (MoveFlag == true)
{
// 壁ポリゴンの数だけ繰り返し
for (i = 0; i < KabeNum; i++)
{
// i番目の壁ポリゴンのアドレスを壁ポリゴンポインタ配列から取得
Poly = Kabe[i];
// ポリゴンとプレイヤーが当たっていなかったら次のカウントへ
if (HitCheck_Capsule_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), PLAYER_HIT_WIDTH, Poly->Position[0], Poly->Position[1], Poly->Position[2]) == FALSE) continue;
// ここにきたらポリゴンとプレイヤーが当たっているということなので、ポリゴンに当たったフラグを立てる
HitFlag = true;
// 壁に当たったら壁に遮られない移動成分分だけ移動する
{
VECTOR SlideVec; // プレイヤーをスライドさせるベクトル
// 進行方向ベクトルと壁ポリゴンの法線ベクトルに垂直なベクトルを算出
SlideVec = VCross(MoveVec, Poly->Normal);
// 算出したベクトルと壁ポリゴンの法線ベクトルに垂直なベクトルを算出、これが
// 元の移動成分から壁方向の移動成分を抜いたベクトル
SlideVec = VCross(Poly->Normal, SlideVec);
// それを移動前の座標に足したものを新たな座標とする
NowPos = VAdd(PrevPos, SlideVec);
}
// 新たな移動座標で壁ポリゴンと当たっていないかどうかを判定する
for (j = 0; j < KabeNum; j++)
{
// j番目の壁ポリゴンのアドレスを壁ポリゴンポインタ配列から取得
Poly = Kabe[j];
// 当たっていたらループから抜ける
if (HitCheck_Capsule_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), PLAYER_HIT_WIDTH, Poly->Position[0], Poly->Position[1], Poly->Position[2]) == TRUE) break;
}
// j が KabeNum だった場合はどのポリゴンとも当たらなかったということなので
// 壁に当たったフラグを倒した上でループから抜ける
if (j == KabeNum)
{
HitFlag = false;
break;
}
}
}
else
{
// 移動していない場合の処理
// 壁ポリゴンの数だけ繰り返し
for (i = 0; i < KabeNum; i++)
{
// i番目の壁ポリゴンのアドレスを壁ポリゴンポインタ配列から取得
Poly = Kabe[i];
// ポリゴンに当たっていたら当たったフラグを立てた上でループから抜ける
if (HitCheck_Capsule_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), PLAYER_HIT_WIDTH, Poly->Position[0], Poly->Position[1], Poly->Position[2]) == TRUE)
{
HitFlag = true;
break;
}
}
}
// 壁に当たっていたら壁から押し出す処理を行う
if (HitFlag == true)
{
// 壁からの押し出し処理を試みる最大数だけ繰り返し
for (k = 0; k < HIT_TRYNUM; k++)
{
// 壁ポリゴンの数だけ繰り返し
for (i = 0; i < KabeNum; i++)
{
// i番目の壁ポリゴンのアドレスを壁ポリゴンポインタ配列から取得
Poly = Kabe[i];
// プレイヤーと当たっているかを判定
if (HitCheck_Capsule_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), PLAYER_HIT_WIDTH, Poly->Position[0], Poly->Position[1], Poly->Position[2]) == FALSE) continue;
// 当たっていたら規定距離分プレイヤーを壁の法線方向に移動させる
NowPos = VAdd(NowPos, VScale(Poly->Normal, HIT_SLIDE_LENGTH));
// 移動した上で壁ポリゴンと接触しているかどうかを判定
for (j = 0; j < KabeNum; j++)
{
// 当たっていたらループを抜ける
Poly = Kabe[j];
if (HitCheck_Capsule_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), PLAYER_HIT_WIDTH, Poly->Position[0], Poly->Position[1], Poly->Position[2]) == TRUE) break;
}
// 全てのポリゴンと当たっていなかったらここでループ終了
if (j == KabeNum) break;
}
// i が KabeNum ではない場合は全部のポリゴンで押し出しを試みる前に全ての壁ポリゴンと接触しなくなったということなのでループから抜ける
if (i != KabeNum) break;
}
}
}
/******4に続く*****/
}
最後に床と衝突しているか走査する。床と衝突していた場合、衝突した床のポリゴンの中で一番Y座標が高いものを3DモデルのY座標とし、着地をしたとしてジャンプをしていない状態にする。
ジャンプかつ上昇中だった場合は天井に衝突したということになるのでその場合は天井のY座標から PolyOffset 分だけ下のY座標を3DモデルのY座標とし、JumpPower を反転させる。
床に衝突していなかった場合は空中にいることになるので落下状態にして JumpPower も設定する。
/*****4*****/
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
/*****3の続き*****/
if (YukaNum != 0)
{
// ジャンプ中且つ上昇中の場合は処理を分岐
if ((PrevJumpStatus == (char)JUMP_HIGH || PrevJumpStatus == (char)JUMP_LOW) && *JumpPower > 0)
{
float MinY;
// 天井に頭をぶつける処理を行う
// 一番低い天井にぶつける為の判定用変数を初期化
MinY = 0.0f;
// 当たったかどうかのフラグを当たっていないを意味する0にしておく
HitFlag = false;
// 床ポリゴンの数だけ繰り返し
for (i = 0; i < YukaNum; i++)
{
// i番目の床ポリゴンのアドレスを床ポリゴンポインタ配列から取得
Poly = Yuka[i];
// 足先から頭の高さまでの間でポリゴンと接触しているかどうかを判定
LineRes = HitCheck_Line_Triangle(NowPos, VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), Poly->Position[0], Poly->Position[1], Poly->Position[2]);
// 接触していなかったら何もしない
if (LineRes.HitFlag == FALSE) continue;
// 既にポリゴンに当たっていて、且つ今まで検出した天井ポリゴンより高い場合は何もしない
if (HitFlag == true && MinY < LineRes.Position.y) continue;
// ポリゴンに当たったフラグを立てる
HitFlag = true;
// 接触したY座標を保存する
MinY = LineRes.Position.y;
}
// 接触したポリゴンがあったかどうかで処理を分岐
if (HitFlag == true)
{
// 接触した場合はプレイヤーのY座標を接触座標を元に更新
NowPos.y = MinY - PolyOffset;
// Y軸方向の速度は反転
*JumpPower = *JumpPower * -1.0f;
}
}
else
{
float MaxY;
// 下降中かジャンプ中ではない場合の処理
// 床ポリゴンに当たったかどうかのフラグを倒しておく
HitFlag = false;
// 一番高い床ポリゴンにぶつける為の判定用変数を初期化
MaxY = 0.0f;
// 床ポリゴンの数だけ繰り返し
for (i = 0; i < YukaNum; i++)
{
// i番目の床ポリゴンのアドレスを床ポリゴンポインタ配列から取得
Poly = Yuka[i];
// ジャンプ中かどうかで処理を分岐
if (PrevJumpStatus == (char)JUMP_HIGH || PrevJumpStatus == (char)JUMP_LOW || PrevJumpStatus == (char)FALL)
{
// ジャンプ中の場合は頭の先から足先より少し低い位置の間で当たっているかを判定
LineRes = HitCheck_Line_Triangle(VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), VAdd(NowPos, VGet(0.0f, -0.2f, 0.0f)), Poly->Position[0], Poly->Position[1], Poly->Position[2]);
}
else
{
// 走っている場合は頭の先からそこそこ低い位置の間で当たっているかを判定( 傾斜で落下状態に移行してしまわない為 )
LineRes = HitCheck_Line_Triangle(VAdd(NowPos, VGet(0.0f, PolyOffset, 0.0f)), VAdd(NowPos, VGet(0.0f, -0.3f, 0.0f)), Poly->Position[0], Poly->Position[1], Poly->Position[2]);
}
// 当たっていなかったら何もしない
if (LineRes.HitFlag == FALSE) continue;
// 既に当たったポリゴンがあり、且つ今まで検出した床ポリゴンより低い場合は何もしない
if (HitFlag == true && MaxY > LineRes.Position.y) continue;
// ポリゴンに当たったフラグを立てる
HitFlag = true;
// 接触したY座標を保存する
MaxY = LineRes.Position.y;
}
// 床ポリゴンに当たったかどうかで処理を分岐
if (HitFlag == true)
{
// 当たった場合
// 接触したポリゴンで一番高いY座標をプレイヤーのY座標にする
NowPos.y = MaxY;
// Y軸方向の移動速度は0に
*JumpPower = 0.0f;
// もしジャンプ中だった場合は着地状態にする
if (PrevJumpStatus == (char)JUMP_HIGH || PrevJumpStatus == (char)JUMP_LOW || PrevJumpStatus == (char)FALL)
{
*JumpStatus = (char)NO_JUMP;
}
}
else
{
// 床コリジョンに当たっていなくて且つジャンプ状態ではなかった場合は
if (PrevJumpStatus == (char)NO_JUMP)
{
// 落下中にする
*JumpStatus = (char)FALL;
// ちょっとだけ落下する
*JumpPower = -0.01f;
}
}
}
}
/*****5に続く*****/
//座標を更新する
*Position = NowPos;
// 検出したモデルの周囲のポリゴン情報を開放する
MV1CollResultPolyDimTerminate(HitDim);
}
あとは3Dモデルの座標を更新し、検出したポリゴン情報を開放する。ポリゴン情報の開放を忘れるとメモリが圧迫するので絶対に開放すること。
/*****5*****/
void Map_CheckCollision(VECTOR* Position, VECTOR PolyPos1, VECTOR PolyPos2, VECTOR MoveVec, char* JumpStatus, float* JumpPower) {
/*****4の続き*****/
//座標を更新する
*Position = NowPos;
// 検出したモデルの周囲のポリゴン情報を開放する
MV1CollResultPolyDimTerminate(HitDim);
}
そしてPlayer.cppに当たり判定処理を行う関数と必要な準備を書く。
//以下の2つをインクルード
#include "Map.h"
#include "Define.h"
//以下の変数を定義
const static float PLAYER_HIT_HEIGHT = 1.3f; //当たり判定用の高さ
//更新
void Player_Update() {
//中略
//プレイヤーの移動方向にモデルの方向を近づける
Player_AngleUpdate();
/*****ここから追加******/
//当たり判定に使用するための下準備
VECTOR PolyPos1, PolyPos2;
//PolyPos1を求める
PolyPos1 = player.Position;
//PolyPos2はPolyPos1より指定分高い座標
PolyPos2 = PolyPos1;
PolyPos2.y += PLAYER_HIT_HEIGHT;
//マップとの当たり判定
Map_CheckCollision(&player.Position, PolyPos1, PolyPos2, MoveVec, &player.JumpStatus, &player.JumpPower);
//プレイヤーの座標の更新
MV1SetPosition(player.ModelHandle, player.Position);
//中略
}
最後にGame.cppにマップ関連の処理を追加する。
Game.cpp
//以下を追加
#include "Map.h"
//中略
//初期化
void Game_Initialize() {
//マップ情報初期化
Map_Initialize();
//中略
}
//更新
void Game_Update() {
//マップ情報更新(プレイヤーより前にすること)
Map_Update();
//中略
}
//描画
void Game_Draw() {
//中略
//マップの描画
Map_Draw();
//中略
}
//終了処理
void Game_Finalize() {
Map_Finalize();
//中略
}
注意事項
今回DXライブラリ置き場のサンプルコードを参考にしたが、そこにも書いてある通り90度以下の鋭角な角に入り込んだ場合すり抜けやハマリが発生する場合がある。
このコード故のバグに見えるがUnityでも同じように90度以下の鋭角な角ですり抜けやハマリが発生するのを確認しているのでアルゴリズムというよりは3Dモデルの移動速度や当たり判定の大きさ、押し出しの距離などが複合的に絡んでこうなっていると思われる。
ゲームによっては鋭角な角がないマップを作ることで回避できるが、無理な場合は一度実装したら鋭角な角に衝突してもすり抜けない、ハマらない値をがんばって見つけましょう。