🎉

【ゲームプログラミングC++】Chapter3課題に回答してみた(ベクトル・円コリジョン・リスポーン処理・ニュートン物理学)

2024/11/28に公開

課題3.1

ベクトル計算課題

課題1は純粋な算術問題メインのため、ここではベクトル算術の中で特に大事になってくるものの説明を行います。回答となるコードは最後にまとめて提示します。

正規化(単位ベクトル作成)

正規化は、ベクトルを長さ1(単位ベクトル)にする行為で、向きを表現するときに使用します(向きに長さは関係ないので)。

\hat{a} = \frac {\vec{a}} {||\vec{a}||}

また、正確な長さが不要な計算の際に正規化をすると、処理の単純化・高速化につながるメリットもあります。

ドット積(内積)|2つのベクトルのなす角

内積そのものを使うというより、内積の式を変形し、2つのベクトルのなす角度を求めるために使います。

\vec{a} \cdot \vec{b}= ||\vec{a}||\space ||\vec{b}||\cos\theta

上記の内積の公式をベースに、cosの逆関数であるarccosを使い以下のように変形することで、角度が求められます。

\theta = \arccos{ \left( \frac {\vec{a} \cdot \vec{b}} {||\vec{a}|| \space ||\vec{b}||} \right )}

さらにここで正規化をすると、式がより簡単になります。

\theta = \arccos{ \left( \hat{a} \cdot \hat{b} \right) }
\hat{a} = \frac {\vec{a}} {||\vec{a}||} ,\space \hat{b} = \frac {\vec{b}} {||\vec{b}||}

上記一連の式を使って角度を求める式は汎用化した方がよいと考えたため、Math.cppに追加しています。
引数は他の関数に合わせて参照を受け取るようにしています。

Math.h
 	// Transform vector by matrix
 	static Vector2 Transform(const Vector2& vec, const class Matrix3& mat, float w = 1.0f);
+	static float CalcAngle(const Vector2& vec1, const Vector2& vec2);
Math.cpp
+ float Vector2::CalcAngle(const Vector2& vec1, const Vector2& vec2)
+ {
+ 	Vector2 vec1N = Vector2::Normalize(vec1);
+  
+ 	Vector2 vec2N = Vector2::Normalize(vec2);
+ 
+  	float dot = Vector2::Dot(vec1N, vec2N);
+ 	float angle = Math::Acos(dot);
+ 
+ 	return angle;
+ }

 Vector3 Vector3::Transform(const Vector3& vec, const Matrix4& mat, float w /*= 1.0f*/)

なお、他にも2点間を結ぶベクトルのグローバル角度(x軸となす角度のようなもの)を求める式もありますが、課題では使用してないので割愛します。

クロス積(外積)|面に垂直なベクトル

クロス積は面に垂直なベクトル(法線)を求める時に使います。
(具体的には3Dグラフィックスなどで法線を扱うため役立つみたいですが、本Chapter外の話になるため、いったん忘れて大丈夫です)

平行ではない2つのベクトルがあれば、それを含む1つの平面が存在します(2Dゲームであれば、画面のディスプレイがそれに該当するもの、ととらえてもよいと思います)。

クロス積の公式は以下の通りです。

\begin{align*} \vec{c} &=\vec{a} × \vec{b} \\ &=\left< a_yb_z - a_zb_y, \space a_zb_x - a_xb_z, \space a_xb_y - a_yb_x \space \right> \end{align*}

添え字の順番が難しいですが、以下のように添え字がx ⇒ y ⇒ z ⇒ x ⇒ ...とループすると把握できれば覚えやすいそうです。

\begin{align*} &c_x = a_yb_z - a_zb_y \\ &c_y = a_zb_x - a_xb_z \\ &c_z = a_xb_y - a_yb_x \\ \end{align*}

Math.hには、Vector2をVector3に変換するコンストラクタを追加しています。

Math.h
 	explicit Vector3(float inX, float inY, float inZ)
 		:x(inX)
 		, y(inY)
 		, z(inZ)
 	{}
 
+	explicit Vector3(Vector2 vec)
+		:x(vec.x)
+		, y(vec.y)
+		, z(0)
+	{}

+	explicit Vector3(Vector2 vec, float inZ)
+		:x(vec.x)
+		, y(vec.y)
+		, z(inZ)
+	{}

実装コード

Gameクラスに、各問題の答えを求めるコードを書きます。

Game.h
 	void GenerateOutput();
 	void LoadData();
 	void UnloadData();

+	void OutputProblem1();
+	void OutputProblem2();
+	void OutputProblem3()
Game.cpp
 void Game::RunLoop()
 {
+	OutputProblem1();
+	OutputProblem2();
+	OutputProblem3();

     // 以下略
 }
Game.cpp
void Game::OutputProblem1()
{
	Vector2 a(2, 4);
	Vector2 b(3, 5);
	float s = 2;
	std::cout << 【課題3.1-1】a=<2,4>, b=<3,5>, s=2" << std::endl;

	Vector2 ansA = a + b;// <2+3, 4+5> = <5,9>
	std::cout << "(a): a+b = " << "<" << ansA.x << "," << ansA.y << ">" << std::endl;

	Vector2 ansB = s * a;// <2*2, 2*4> = <4, 8>
	std::cout << "(b): s.a = " << "<" << ansB.x << "," << ansB.y << ">" << std::endl;

	float ansC = Vector2::Dot(a, b);//2*3+4*5 = 26
	std::cout << "(c): a.b = " << ansC << std::endl;

	std::cout << "----- " << std::endl;
}

void Game::OutputProblem2()
{
	Vector2 a(-1, 1);
	Vector2 b(2, 4);
	Vector2 c(3, 3);
	std::cout << 【課題3.1-2】A=<-1,1>, B=<2,4>, C=<3, 3>, ABとACのなす角は?" << std::endl;

	//ABを求める
	Vector2 ab(b - a);//<3, 3>
	Vector2 abN = Vector2::Normalize(ab);//<3, 3>

	//ACを求める
	Vector2 ac(c - a);//<4, 2>
	Vector2 acN = Vector2::Normalize(ac);//<4, 2>

	//AcosにABとACの正規化した値のドット積を与える
	float dot = Vector2::Dot(abN, acN);
	float angle = Math::Acos(dot);

	std::cout << "AB = " << "<" << ab.x << "," << ab.y << ">" << std::endl;
	std::cout << "AB^ = " << "<" << abN.x << "," << abN.y << ">" << std::endl;
	std::cout << "AC = " << "<" << ac.x << "," << ac.y << ">" << std::endl;
	std::cout << "AC^ = " << "<" << acN.x << "," << acN.y << ">" << std::endl;
	std::cout << "AB^.AC^ = " << dot << std::endl;
	std::cout << "θ(ABとACのなす角) = " << angle << std::endl;
	std::cout << "θ(ABとACのなす角) = " << Vector2::CalcAngle(ab, ac) << std::endl;//角度計算関数の場合
	std::cout << "----- " << std::endl;
}

oid Game::OutputProblem3()
{
	Vector2 wp(1, 0);		//現在の目標位置
	Vector2 player(4, 0);	//プレイヤーの位置
	Vector2 nwp(5, 6);		//新しい目標位置
	std::cout << "【課題3.1-3】" << std::endl;

	Vector2 nwpN = Vector2::Normalize(nwp);
	std::cout << "(a): newWaypoint(player→nwp)の単位ベクトル = " <<
		"<" << nwpN.x << "," << nwpN.y << ">"
		<< std::endl;

	Vector2 pToWp = wp - player;
	Vector2 pToNwp = nwp - player;
	std::cout << "(b): playerからみた新旧waypointの回転角 = " << Vector2::CalcAngle(pToWp, pToNwp) << std::endl;

	Vector3 pToWp3(pToWp);
	Vector3 pToNwp3(pToNwp);
	Vector3 cross = Vector3::Cross(pToWp3, pToNwp3);
	std::cout << "(c): 新旧waypointからなる平面に直行するベクトル " <<
		"<" << cross.x << "," << cross.y << "," << cross.z << ">"
		<< std::endl;
}

最終的な出力は以下の通り(「^」がついてるものは単位ベクトル)

【課題3.1-1】a=<2,4>, b=<3,5>, s=2
(a): a+b = <5,9>
(b): s.a = <4,8>
(c): a.b = 26
-----
【課題3.1-2】A=<-1,1>, B=<2,4>, C=<3, 3>, ABとACのなす角は?
AB = <3,3>
AB^ = <0.707107,0.707107>
AC = <4,2>
AC^ = <0.894427,0.447214>
AB^.AC^ = 0.948683
θ(ABとACのなす角) = 0.32175
θ(ABとACのなす角) = 0.32175
-----
【課題3.1-3】
(a): newWaypoint(player→nwp)の単位ベクトル = <0.640184,0.768221>
(b): playerからみた新旧waypointの回転角 = 1.73594
(c): 新旧waypointからなる平面に直行するベクトル <0,0,-18>

課題3.2

宇宙船(プレイヤー)にコリジョンを追加し、隕石と衝突したら回転角を0に, 位置を画面中央にリセットしよう。
また、隕石に衝突してからリセットまで1~2秒間、宇宙船の姿を消すようにしよう

実装方針

衝突検証
ShipにAsteroidと似たような円コリジョンを持たせます。
GameクラスがAsteroidだけを管理している配列を持っているため、Ship内でそれを呼び出し、当たり判定を行えば完了です。

リスポーン処理
まずenumのShipStateを持たせて、生死判定をします。
コリジョンが重なったらStateをDead(やられ)とし、テクスチャをnullptrにし、画面から消えたようにふるまわせます。
加えて、前述の衝突検証はAliveの時にのみ行うようにします。

リスポーン処理は「リスポーン時間」と「リスポーンまでの残り時間」を変数として持たせて対応します(レーザーのクールダウンと似た処理)。
ShipStateがDead(やられ)時、残り時間を減算。
残り時間が0以下になったら、位置と角度と残り時間を初期化。テクスチャを再設定し、StateをAliveにします。

実装コード

Ship.h
 public:

    // 略

+ 	class CircleComponent* GetCircle() { return mCircle; }
 private:
+	enum class ShipState {
+		Alive, Dead
+	};
+	ShipState mState;

+	class SpriteComponent* mSc;

 	float mLaserCooldown;
+ 	class CircleComponent* mCircle;

+ 	float mRespawnTime;
+ 	float mRespawnTimeRemaining;

コンストラクタにコリジョンと、テクスチャリセット用の変数を追加します。

Ship.cpp
 Ship::Ship(Game* game)
 	:Actor(game)
 	, mLaserCooldown(0.0f)
+	, mRespawnTime(2.0f)
+	, mRespawnTimeRemaining(mRespawnTime)
+	, mState(ShipState::Alive)
 {
 	// Create a sprite component
+	mSc = new SpriteComponent(this, 150);
+	mSc->SetTexture(game->GetTexture("Assets/Ship.png"));

 	// Create an input component and set keys/speed
 	InputComponent* ic = new InputComponent(this);

 	ic->SetCounterClockwiseKey(SDL_SCANCODE_D);
 	ic->SetMaxForwardSpeed(300.0f);
 	ic->SetMaxAngularSpeed(Math::TwoPi);

+ 	mCircle = new CircleComponent(this);
+ 	mCircle->SetRadius(40.0f);
 }

UpdateActorにリスポーン処理を追加。
未変更処理はレーザーのクールダウンのみなため、diffは示さず全体をそのまま添付しています。

Ship.cpp
void Ship::UpdateActor(float deltaTime)
{
	mLaserCooldown -= deltaTime;

	//gameからmAsteeroidを取得
	Game* game = GetGame();
	std::vector<Asteroid*> asteroids = game->GetAsteroids();

	switch (mState)
	{
	case ShipState::Alive:
		for (auto asteroid : asteroids)
		{
			if (Intersect(*asteroid->GetCircle(), *mCircle))
			{
				mState = ShipState::Dead;
				mSc->SetTexture(nullptr);
			}
		}
		break;
	case ShipState::Dead:
		mRespawnTimeRemaining -= deltaTime;

		if (mRespawnTimeRemaining <= 0.0f) {
			mRespawnTimeRemaining = mRespawnTime;

			SetPosition(Vector2(512.0f, 384.0f));
			SetRotation(Math::PiOver2);

			mState = ShipState::Alive;
			mSc->SetTexture(game->GetTexture("Assets/Ship.png"));
		}
		break;
	default:
		break;
	}
}

やられ中にレーザーを射出しないようにもします。

Ship.cpp
 void Ship::ActorInput(const uint8_t* keyState)
 {
+	if (keyState[SDL_SCANCODE_SPACE] && mLaserCooldown <= 0.0f && mState != ShipState::Dead)
 	{
 		// Create a laser and set its position/rotation to mine
 		Laser* laser = new Laser(GetGame());
 		laser->SetPosition(GetPosition());
 		laser->SetRotation(GetRotation());

 		// Reset laser cooldown (half second)
 		mLaserCooldown = 0.5f;
    }
 }

課題3.3

MoveComponent(移動を計算するクラス)にニュートン物理学を取り入れよう。
合わせて、関連するクラスの処理にニュートン物理学をサポートさせて正しく動くようにしよう。

実装方針

現状の移動計算は x=x_0+vt の式のみを使用しています。
これを、加速度と撃力を含めた式に変換し、MoveComponent利用側では、速度を設定する方法から、撃力を設定(加算)する方法に変換します。

\begin{align*} & a=\frac{F}{m} \space (⇐F=ma) \\ & v=v_0+at \\ & x=x_0+vt \end{align*}

(2段目の速度計算式は、本来であれば v=v_0 + \frac{1}{2}at^2 だが、ここでは簡略化されている)

注意点ですが、t(deltaTime)=1は1秒となっているため、計算中は極めて小さいtが追加で一回乗算されることになります。
そのため、撃力はかなり大きめに設定する必要があります(単純にforwardSpeedを撃力に置き換えてもほとんど移動しない)。

また上記に加えて摩擦力を追加しています。
主に、キー未入力時にShipを自然減速させるために使用します。
また、ピタッと移動を停止するためのResetVelocity()も追加しています。これはShipとAsteroidが衝突した時に使用しています。

実装コード

MoveComponent利用側で変更が必要なのは、ShipとAsteroid、Laser、そしてMoveComponentを継承しているInputComponentです。

実際の修正に移る前に、 a=\frac{F}{m} の計算でVector2 ÷ floatの処理が簡単にできるよう除算演算子を追加します。

Math.h
 	// Scalar multiplication
 	friend Vector2 operator*(float scalar, const Vector2& vec)
 	{
 		return Vector2(vec.x * scalar, vec.y * scalar);
 	}

+	// Scalar division
+	friend Vector2 operator/(const Vector2& vec, float scalar)
+	{
+		return Vector2(vec.x / scalar, vec.y / scalar);
+	}

次にMoveComponentを修正。
AddForce()は複数用意しました。
それぞれ上から順に以下のようなユースケースです。

  • 任意の方向・撃力で移動させたい(Vector2 force)
  • 前方方向に、任意の撃力で移動させたい(float force)
  • 前方方向に、mForwardSpeedを元にした撃力で移動させたい(引数なし)
MoveComponent.h
 class MoveComponent : public Component
 {
 public:
    //略

 	MoveComponent(class Actor* owner, int updateOrder = 10);

 	void Update(float deltaTime) override;
	

 	float GetAngularSpeed() const { return mAngularSpeed; }
 	float GetForwardSpeed() const { return mForwardSpeed; }
 	void SetAngularSpeed(float speed) { mAngularSpeed = speed; }
 	void SetForwardSpeed(float speed) { mForwardSpeed = speed; }

+	void AddForce(Vector2 force);
+	void AddForce(float force);
+	void AddForce();
+	void SetFriction(float friction) { mFriction = friction; }
+	void ResetVelocity() { mVerocity = Vector2(); }
 private:
 	// Controls rotation (radians/second)
 	float mAngularSpeed;
 	// Controls forward movement (units/second)
 	float mForwardSpeed;

+	float mMass = 1.0f;// 0除算回避
+	Vector2 mForce;
+	Vector2 mVerocity;
+
+	float mFriction = 0;
 };

コンストラクタに初期化処理を追加します。

MoveComponent.cpp
 MoveComponent::MoveComponent(class Actor* owner, int updateOrder)
 	:Component(owner, updateOrder)
 	, mAngularSpeed(0.0f)
 	, mForwardSpeed(0.0f)
+	, mForce(Vector2())
+	, mFriction(0.0f)

Update()にニュートン物理学の処理を追加します。
この計算は毎フレーム行いたいため、if文を削除します。
速度と撃力がNearZeroなら処理を飛ばすようにしてもいいかもですね。

撃力は、付与した瞬間しか維持しないため、最後にmForceを0にリセットします。

MoveComponent.cpp
 void MoveComponent::Update(float deltaTime)
 {

    // 略

 		rot += mAngularSpeed * deltaTime;
 		mOwner->SetRotation(rot);
 	}

+	//if (!Math::NearZero(mForwardSpeed)) // 物理計算は常に行われるため、条件を外す
+
+	// F=m.aからa=F/mをし、加速度を求める(空気抵抗も考慮)
+	Vector2 acceleration = (mForce - mVerocity * mFriction) / mMass;
+
+	// v=v0+atから現在の速度にa*deltatimeを加算
+	mVerocity += acceleration * deltaTime;
+
+	//x=x0+v0tから現在の位置にv*deltatimeを加算
+	Vector2 pos = mOwner->GetPosition();
+	pos += mVerocity * deltaTime;//deltaTimeが2回掛かってるから極端に遅くなる

 	// (Screen wrapping code only for asteroids)
 	if (pos.x < 0.0f) { pos.x = 1022.0f; }
 	else if (pos.x > 1024.0f) { pos.x = 2.0f; }

 	if (pos.y < 0.0f) { pos.y = 766.0f; }
 	else if (pos.y > 768.0f) { pos.y = 2.0f; }
 
	mOwner->SetPosition(pos);

+	mForce = Vector2();
 }

+ void MoveComponent::AddForce(Vector2 force)
+ {
+ 	mForce += force;
+ }
+ 
+ void MoveComponent::AddForce(float force)
+ {
+ 	AddForce(mOwner->GetForward() * force);
+ }
+
+ void MoveComponent::AddForce()
+ {
+ 	// deltaTImeが追加で1回乗算される分、1/60秒でかける撃力を強化する
+ 	AddForce(mForwardSpeed * 60);
+ }

InputComponentでも撃力を使用するよう修正します。

InputComponent.cpp
 void InputComponent::ProcessInput(const uint8_t* keyState)
 {
    //略

 	SetForwardSpeed(forwardSpeed);

+ 	AddForce();

 	// Calculate angular speed for MoveComponent
 	float angularSpeed = 0.0f;

    // 略
 }

AsteroidにはMoveComponentをクラス変数として持たせます。
(他のクラスと形式を合わせるために持たせていますが、コンストラクタ以外では現状使用していないため冗長気味ですね)

Asteroid.h
 private:
 	class CircleComponent* mCircle;
+	class MoveComponent* mMove;

Asteroidは初速があればよいので、コンストラクタ内でAddForceを呼び出します。

Asteroid.cpp
 Asteroid::Asteroid(Game* game)
 	:Actor(game)
 	, mCircle(nullptr)
{
    //略

 	sc->SetTexture(game->GetTexture("Assets/Asteroid.png"));

+	mMove = new MoveComponent(this);
+	mMove->SetForwardSpeed(150.0f);

 	// Create a circle component (for collision)
 	mCircle = new CircleComponent(this);
 	mCircle->SetRadius(40.0f);

+	mMove->AddForce(150.0f * 60);

 	// Add to mAsteroids in game
 	game->AddAsteroid(this);
}

ShipにはInputComponentを変数として持たせ、自身で速度をある程度制御できるようにします。

Ship.h
 private:
  	enum class ShipState {
 		Alive, Dead
 	};
 	ShipState mState;


+	class InputComponent* mInput;
 	class SpriteComponent* mSc;

コンストラクタは元々行っていたInputComponentの初期化処理に摩擦力の設定を追加しています。

Ship.cpp
 Ship::Ship(Game* game)
 	:Actor(game)
 	, mLaserCooldown(0.0f)
 	, mRespawnTime(2.0f)
 	, mRespawnTimeRemaining(mRespawnTime)
 	, mState(ShipState::Alive)
 {
 	// 略
     
 	// Create an input component and set keys/speed
+	mInput = new InputComponent(this);
+	mInput->SetForwardKey(SDL_SCANCODE_W);
+	mInput->SetBackKey(SDL_SCANCODE_S);
+	mInput->SetClockwiseKey(SDL_SCANCODE_A);
+	mInput->SetCounterClockwiseKey(SDL_SCANCODE_D);
+	mInput->SetMaxForwardSpeed(300.0f);
+	mInput->SetMaxAngularSpeed(Math::TwoPi);
+	mInput->SetFriction(60.0f);

 	mCircle = new CircleComponent(this);
 	mCircle->SetRadius(40.0f);
}

UpdateActorでは、Shipやられ時に速度を強制的に0にしています。
ただ、InputComponent側が持つ移動処理は防げていないので、もしかしたら意味ない処理になっているかもしれないですね。

Ship.cpp
 void Ship::UpdateActor(float deltaTime)
 {

        // 略

 			if (Intersect(*asteroid->GetCircle(), *mCircle))
 			{
 				mState = ShipState::Dead;
 				mSc->SetTexture(nullptr);
+				mInput->ResetVelocity();
+				//速度を0にする
 			}
        // 略
 }

Laser生成時、addForceを呼び出すのを忘れないように。
また、SetRotationを呼び出した後に呼び出すようにしないと、明後日の方向に弾が飛んでしまうため注意です。

Ship.cpp
 void Ship::ActorInput(const uint8_t* keyState)
 {
 	if (keyState[SDL_SCANCODE_SPACE] && mLaserCooldown <= 0.0f && mState != ShipState::Dead)
 	{
 		// Create a laser and set its position/rotation to mine
 		Laser* laser = new Laser(GetGame());
 		laser->SetPosition(GetPosition());
 		laser->SetRotation(GetRotation());
+		laser->AddForce();

 		// Reset laser cooldown (half second)
 		mLaserCooldown = 0.5f;
    }
 }

LaserにもMoveComponentを変数として持たせます。
また、上記の処理のように、Shipが撃力を自身に与えられるよう、AddForce()の追加が必要です。

Laser.h
 class Laser : public Actor
 {
 public:
 	Laser(class Game* game);
 
 	void UpdateActor(float deltaTime) override;
+	void AddForce(float force);
+	void AddForce();
 private:
 	class CircleComponent* mCircle;
 	float mDeathTimer;
+	class MoveComponent* mMove;
 };

laserのコンストラクタ呼び出し時点では、方向が定まっていないため、撃力を追加しても無意味です。
呼び出し時点で方向を定めたい場合、コンストラクタの引数にVector2型のrotateを追加するのがよいでしょう

Laser.cpp
 Laser::Laser(Game* game)
 	:Actor(game)
 	, mDeathTimer(1.0f)
 {
 	// 略

 	// Create a move component, and set a forward speed
+	mMove = new MoveComponent(this);
+	mMove->SetForwardSpeed(800.0f);

 	// Create a circle component (for collision)
 	mCircle = new CircleComponent(this);

    // 略
 }

+void Laser::AddForce(float force)
+{
+	mMove->AddForce(force);
+}
+
+void Laser::AddForce()
+{
+	mMove->AddForce(mMove->GetForwardSpeed() * 60);
+}

課題3.3はこれにて終了です!

まとめ

実装して動かしたものを添付しておきます。
たまに長時間リスポーンされない瞬間がありますが、リスポーン地点に隕石が存在しているのが原因です。
https://x.com/KEG_game/status/1861762067348517156

Discussion