Xcode与C++之游戏开发:向量与物理基础

由于代码渐渐多了起来,现在参考实现已经上传到 Github 了。 github仓库地址

上一篇:Xcode与C++之游戏开发:精灵(Sprite

既然没有用游戏引擎,再往下就一定要遇到遇到怎么处理子弹反弹,更高级一点诸如渲染阴影。这些游戏必备的数学主要和线性代数中向量有关。再广义一点,游戏还涉及音频的播放之类,如果是联网游戏有游戏玩家间的交流,可能还有处理音频的过程(比如,适当压缩)来减少网络负担,降低延迟。

向量运算

二维向量需要由两个坐标轴来刻画 ( x , y ) (x,y) (x,y),三维向量(空间向量)需要由三个坐标轴来刻画 ( x , y , z ) (x,y,z) (x,y,z)。依此类推,四维向量需要四个,例如在相对论中,除了空间意义上的 ( x , y , z ) (x,y,z) (x,y,z),加上了时间意义的上的 t t t,构成了爱因斯坦描述的四维空间。但是,处于三维空间的我们是无法画出这玩意的。换句话说,可视化只能最多是三维的。

向量的运算和算术运算有一些差异。有一些简单的概念可以稍微复习一下:

  • 向量的加(减)法:对应坐标维度相加(减): ( x 1 , y 1 ) + ( x 2 , y 2 ) = ( x 1 + x 2 , y 1 + y 2 ) (x_1,y_1)+(x_2,y_2)=(x_1+x_2,y_1+y_2) (x1,y1)+(x2,y2)=(x1+x2,y1+y2) ( x 1 , y 1 ) − ( x 2 , y 2 ) = ( x 1 − x 2 , y 1 − y 2 ) (x_1,y_1)-(x_2,y_2)=(x_1-x_2,y_1-y_2) (x1,y1)(x2,y2)=(x1x2,y1y2)

注意,向量的加减法几何意义上遵循的是三角形法则。

Xcode与C++之游戏开发:向量与物理基础_第1张图片

减法的原则可以简单记为:起点相同,指向被减。(起点不同?那就平移到相同~)
在乒乓球游戏那篇,就已经给出了二维向量的简单实现:

// Vector2 结构体仅存储 x 和 y 坐标
struct Vector2
{
     
  float x;
  float y;
};
  • 向量的数乘,几何意义上是放缩向量的长度: λ ⋅ ( x , y ) = ( λ x , λ y ) \lambda \cdot (x,y)=(\lambda x, \lambda y) λ(x,y)=(λx,λy)
  • 向量的长度(模),这里用到的其实就是勾股定理: ∣ ∣ x ∣ ∣ = a x 2 + a y 2 ||x||=\sqrt{a_x^2+a_y^2} x=ax2+ay2
  • 向量的标准化(归一化?)(通常用于确定向量方向): a ⃗ = ( a ⃗ x ∣ ∣ a ⃗ x ∣ ∣ , a ⃗ y ∣ ∣ a ⃗ y ∣ ∣ ) \vec a = (\frac{\vec a_x}{||\vec a_x||}, \frac{\vec a_y}{||\vec a_y||}) a =(a xa x,a ya y),单位向量:长度为1的向量

C++ 支持重载运算符,可以通过定制相应的运算符,直观的实现一些运算:

Vector2 a;
Vector2 result = 5.0f * a;

弧度与旋转

围绕向量还有一个相关的话题就是关于旋转,这一部分和三角函数紧密相关。向量本身可以解决平移问题,而旋转的问题则会由弧度来解决。弧度和角度的转换,大家估计也都了解过。我们平常说的360度指的是角度,对应的弧度是 2 π 2\pi 2π,那么180度就对应 π \pi π。很多人可能会有疑问,为什么要搞出弧度的概念。最肤浅的理解的就是角度并非数值单位,而弧度是实数,函数上可以做到一一映射。

高中的时候,大家应该也都清楚单位圆和三角函数的关系。在单位圆中,x 轴表示 cos ⁡ \cos cos,y轴表示 sin ⁡ \sin sin。(如果能联系到上面的归一化,估计能想起点什么吧~)

但是,注意,数学上的y轴是向上的,在计算机中,一般是向下的。因此,记得加上负号

Vector3 Actor::GetForward() const
{
     
    return Vector2(Math::Cos(mRotation), -Math::Sin(mRotation));
}

点积

点积又称为数量积,两个向量的点积运算结果是一个,不是一个向量。向量有大小有方向,数是没有方向,只有大小的概念。数量积的运算可以总结成一句话:对应的坐标分量相乘再相加

a ⃗ ⋅ b ⃗ = a ⃗ x ⋅ b ⃗ x + a ⃗ y ⋅ b ⃗ y \vec a \cdot \vec b = \vec a_x \cdot \vec b_x + \vec a_y \cdot \vec b_y a b =a xb x+a yb y

数量积和三角函数中的余弦 cos ⁡ \cos cos 紧密相关,可以等价的用模和余弦函数进行计算:

a ⃗ ⋅ b ⃗ = ∣ a ⃗ ∣ ∣ b ⃗ ∣ ⋅ cos ⁡ θ \vec a \cdot \vec b = | \vec a | |\vec b |\cdot \cos{\theta } a b =a b cosθ

数量积由于是数值,可以直接使用除法运算,进一步可以变换出一个重要公式:

cos ⁡ θ = a ⃗ ⋅ b ⃗ ∣ a ⃗ ∣ ∣ b ⃗ ∣ \cos{\theta } = \frac{\vec a \cdot \vec b}{| \vec a | |\vec b |} cosθ=a b a b

如果想问, θ \theta θ 怎么求,那还有反三角函数 arccos ⁡ \arccos arccos。点积的物理意义是物体做功的大小,数学上则代表一个向量在另外一个向量上进行的垂直投影

Xcode与C++之游戏开发:向量与物理基础_第2张图片

叉积

叉积主要是用来确定平面的,可以理解为一个平面的法向量(对应的一组概念:切向量),即运算得到的向量垂直于原来的两个向量。叉积的运算结果是一个向量,而且一般的符号就是我们使用的乘法符号 × \times ×。运算的规则其实就是线性代数中矩阵的行列式。我个人推荐直接用行列式规则进行记忆,不要去背公式。

c ⃗ = a ⃗ × b ⃗ = ∣ 1 1 1 a x a y a z b x b y b z ∣ = ( a y b z − a z b y , a z b x − a x b z , a x b y − a y b x ) \vec c = \vec a \times \vec b = \left |\begin{array}{cccc} 1 &1 & 1 \\ a_x & a_y & a_z \\ b_x & b_y & b_z \\ \end{array}\right| = (a_y b_z - a_z b_y, a_z b_x - a_x b_z, a_x b_y - a_y b_x) c =a ×b =1axbx1ayby1azbz=(aybzazby,azbxaxbz,axbyaybx)
注意,其实就是把对应的分量(那一列)去掉,然后交叉相乘再相减。这个过程就是线性代数中三阶行列式降维(拆分成二维行列式)的计算。注意,中间(第二个)的时候,是反过来的(乘了-1,原本交叉相乘再相减是 a x b z − a z b x a_x b_z - a_z b_x axbzazbx,乘-1,就反过来了)。这是由于外面还乘了一个 ( − 1 ) i + j (-1)^{i + j} (1)i+j

牛顿物理学

物理大厦在牛顿之后被重构了两次(好像是吧~):爱因斯坦的相对论,在牛顿基础上进一步揭示力的本质,阐释了相对的时空观,直接把人类对于宇宙的认知提高了一个新的等级;普朗克开始的量子力学,在微观领域又把人类推入不确定的深渊。直到今天,人类还是不知道上帝到底掷不掷骰子。

由于我们身处的世界里体验不到爱因斯坦相对论中的宏观高速,达不到量子力学的微观层次。我们通常还是活在牛顿力学适用的宏观低速的世界中。要在游戏里模拟真实世界,构建一个符合牛顿力学的世界是非常有必要的,这也意味着不得不回顾一下牛顿的力学。

(此处不打算写复杂的力学,就只讨论简单的直线运动,不讨论圆周运动,旋转的力学问题)

牛顿的第一定律是所谓的惯性定律,简单来讲,物体的重量大小决定了物体的惯性大小;有公式的是牛顿的第二定律,揭示出力和加速度的关系:
F ⃗ = m ⋅ a ⃗ \vec F=m\cdot \vec a F =ma

F ⃗ \vec F F 和 加速度 a ⃗ \vec a a 都是向量(矢量),物体的质量是标量(数量),其实就是一个向量的数乘。另外,在物理上,不要把等号理解为“等于”,应该理解为“提供”。应该说,力提供加速度,而不是力等于质量乘以加速度。

然而,这种符号公式并不适合用在游戏中。计算机只能表示离散型的结构,因此计算机对于这种运算只能近似的进行模拟。在游戏中,通过对物体施加力,然后确定物体的加速度。有了加速度就可以进一步计算物体的速度改变。这种计算并不是数学上连续意义的计算,会使用某个设定的好的时间间隔 deltatime 重复计算。(这个过程就是积分过程,但不是数学上符号积分,而是工程学上常用的数值积分)

移动组件的实现

移动显然是游戏中极其常见的通用功能。因此利用简单的数学知识,可以抽象出一个通用的移动组件 MoveComponent,在这之后,可以定制一个继承于 MoveComponent 的子类 InputComponent,处理来自用户的键盘输入。

最基础的,MoveComponent 应该可以让 actor 在一个确定的速度上移动。为了实现这个功能,需要一个函数来计算 Actor 方向。有了方向向量,就可以基于速度和 deltaTime 计算出位置:

position += GetForward() * forwardSpeed * deltaTime;

至于旋转,可以使用一个简单的角速度进行计算:

rotation += angularSpeed * deltaTime;

最终代码如下,MoveCompoent.hpp

#include "Component.hpp"

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; }
private:
    // 控制旋转
    float mAngularSpeed;
    // 控制方向移动
    float mForwardSpeed;
};

之后会简单实现一个行星游戏,飞船要击碎陨石或者躲开陨石。可能需要屏幕换行,这样可以实现陨石从左边穿出屏幕,从右边再次出现的效果。如果玩过涂鸦跳跃,应该都知道角色从左边跳离,就会从右边跳出来。

#include "MoveComponent.hpp"
#include "Actor.hpp"

MoveComponent::MoveComponent(class Actor* owner, int updateOrder)
:Component(owner, updateOrder)
,mAngularSpeed(0.0f)
,mForwardSpeed(0.0f)
{
     
    
}

void MoveComponent::Update(float deltaTime)
{
     
    if (!Math::NearZero(mAngularSpeed))
    {
     
        float rot = mOwner->GetRotation();
        rot += mAngularSpeed * deltaTime;
        mOwner->SetRotation(rot);
    }
    
    if (!Math::NearZero(mForwardSpeed))
    {
     
        Vector2 pos = mOwner->GetPosition();
        pos += mOwner->GetForward() * mForwardSpeed * deltaTime;
        // 屏幕换行
        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);
    }
}

接下来,定义一个 Actor 的子类 Asteroid。值得注意的是,Asteroid 不需要重载 UpdateActor 实现移动,只需简单的在构造函数中构造一个 MoveCompoent。同样,实现行星的绘制只需要使用 SpriteComponet。为了根据方便的生成随机的向量值,简单的封装了一个 Random 库,内容不过是简单拓展 C++11 的随机数库(见 Github)

Xcode与C++之游戏开发:向量与物理基础_第3张图片

输入组件的实现

上面的 MoveComponent 对于行星这类不需要依赖外部输入的 Actor 是很好的。不过,像之前的飞船需要由玩家进行控制就显得不是很管用了。一种处理方法就是像 Ship 一样定制输入函数。考虑到玩家控制非常普遍,抽象成独立组件是一个不错选择。

首先在 Component 中添加一个虚 ProcessInput 函数,并提供一个空白的默认实现:

// Component.hpp
virtual void ProcessInput(const uint8_t* keyState) {
     }

然后在 Actor 中声明两个函数:一个非虚的 ProcessInput和虚函数 ActorInput 函数:

// Actor.hpp
// 被 Game 调用的的 ProcessInput(不可重写)
void ProcessInput(const uint8_t* keyState);
// 特定 Actor 输入(可重写)
virtual void ActorInput(const uint8_t* keyState);

Actor::ProcessInput 首先检查 Actor 的状态是否是 Active。如果是的话,在 Actor 的组件上调用 ProcessInput,然后调用 Actor 特定 ActorInput (被重写过的)。

void Actor::ProcessInput(const uint8_t* keyState)
{
     
    if (mState == EActive)
    {
     
        // 首先处理组件的输入
        for (auto comp : mComponents)
        {
     
            comp->ProcessInput(keyState);
        }
        
        ActorInput(keyState);
    }
}

void Actor::ActorInput(const uint8_t* keyState)
{
     
}

最后,在 Game::ProcessInput 中可以循环所有的 actor 并调用 ProcessInput

mUpdatingActors = true;
for (auto actor : mActors)
{
     
    actor->ProcessInput(keyState);
}
mUpdatingActors = false;

有了这些铺垫,就可以定义MoveComponent 的子类 InputComponent

class InputComponent : public MoveComponent
{
     
public:
    // 值低者优先更新
    InputComponent(class Actor* owner);

    void ProcessInput(const uint8_t* keyState) override;
    
    // Getters/setters
    float GetMaxForward() const {
      return mMaxForwardSpeed; }
    float GetMaxAngular() const {
      return mMaxAngularSpeed; }
    int GetForwardKey() const {
      return mForwardKey; }
    int GetBackKey() const {
      return mBackKey; }
    int GetClockwiseKey() const {
      return mClockwiseKey; }
    int GetCounterClockwiseKey() const {
      return mCounterClockwiseKey; }

    void SetMaxForwardSpeed(float speed) {
      mMaxForwardSpeed = speed; }
    void SetMaxAngularSpeed(float speed) {
      mMaxAngularSpeed = speed; }
    void SetForwardKey(int key) {
      mForwardKey = key; }
    void SetBackKey(int key) {
      mBackKey = key; }
    void SetClockwiseKey(int key) {
      mClockwiseKey = key; }
    void SetCounterClockwiseKey(int key) {
      mCounterClockwiseKey = key; }
private:
    // 方向和转向速度的最大值
    float mMaxForwardSpeed;
    float mMaxAngularSpeed;
    
    int mForwardKey;
    int mBackKey;
    
    int mClockwiseKey;
    int mCounterClockwiseKey;
};

具体实现:


InputComponent::InputComponent(class Actor* owner)
:MoveComponent(owner)
,mForwardKey(0)
,mBackKey(0)
,mClockwiseKey(0)
,mCounterClockwiseKey(0)
{
     
    
}

void InputComponent::ProcessInput(const uint8_t* keyState)
{
     
    // 计算方向速度
    float forwardSpeed = 0.0f;
    if (keyState[mForwardKey])
    {
     
        forwardSpeed += mMaxForwardSpeed;
    }
    if (keyState[mBackKey])
    {
     
        forwardSpeed -= mMaxForwardSpeed;
    }
    SetForwardSpeed(forwardSpeed);
    
    // 计算角速度
    float angularSpeed = 0.0f;
    if (keyState[mClockwiseKey])
    {
     
        angularSpeed += mMaxAngularSpeed;
    }
    if (keyState[mCounterClockwiseKey])
    {
     
        angularSpeed -= mMaxAngularSpeed;
    }
    SetAngularSpeed(angularSpeed);
}

有了这些,就可以简单的在 Ship 中创建一个 InputComponent

class Ship : public Actor
{
     
public:
    Ship(class Game* game);
    ~Ship();
    void UpdateActor(float deltaTime) override;
    void ActorInput(const uint8_t* keyState) override;
private:
    SpriteComponent* sc;
    InputComponent* ic;
};

具体实现:

Ship::Ship(Game* game)
: Actor(game)
, sc(new SpriteComponent(this, 150))
, ic(new InputComponent(this))
{
     
  sc->SetTexture(game->GetTexture("Assets/Ship.png"));

  // 设置按键
  ic->SetForwardKey(SDL_SCANCODE_W);
  ic->SetBackKey(SDL_SCANCODE_S);
  ic->SetClockwiseKey(SDL_SCANCODE_A);
  ic->SetCounterClockwiseKey(SDL_SCANCODE_D);
  ic->SetMaxForwardSpeed(300.0f);
  ic->SetMaxAngularSpeed(Math::TwoPi);
}

Ship::~Ship()
{
     
    if (sc) delete sc;
    if (ic) delete ic;
}

Game::LoadData() 中加载飞船:

// 玩家控制的飞船
mShip = new Ship(this);
mShip->SetPosition(Vector2(512.0f, 384.0f));
mShip->SetRotation(Math::PiOver2);

现在就可以通过键盘移动飞船了:

Xcode与C++之游戏开发:向量与物理基础_第4张图片

基本的碰撞检测

在之前的 Pong 游戏中就已经涉及了球对球拍和墙的碰撞检测。游戏中,碰撞检测将决定两个物体如何进行接触。碰撞检测关键点在于简化问题,对于不规则的物体来讲,根据实际的轮廓来进行碰撞检测将会更加准确。但如果我们把游戏对象简化成一个圆,我们更加简便地判断是否碰撞。

要判断两个圆的相交和相离,只需要比较两个圆之间的圆心距离与原半径之和。

假设两个圆心距离为 d d d,两个圆的半径为 r 1 r_1 r1 r 2 r_2 r2 如果两个圆心的距离等于两个圆各自的半径之和,即 d = r 1 + r 2 d = r_1 + r_2 d=r1+r2,则刚好触碰(相切);如果距离大于半径之和,即 d > r 1 + r 2 d > r_1 + r_2 d>r1+r2,则两个圆处于分离状态;同样,如果 $ d < r_1 + r_2$,则两个圆已经相交。

两个点之间的欧几里得距离计算公式如下:

d = ( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} d=(x1x2)2+(y1y2)2

为了实现碰撞检测,我们自然可以封装一个 CircleCompoent 来完成上述计算。

CircleComponent

class CircleComponent : public Component
{
     
public:
    CircleComponent(class Actor* owner);
    
    void SetRadius(float radius) {
      mRadius = radius; }
    float GetRadius() const;
    
    const Vector2& GetCenter() const;
private:
    float mRadius;
};

bool Intersect(const CircleComponent& a, const CircleComponent& b);

碰撞检测是通过 Intersect 实现的。

CircleComponent::CircleComponent(class Actor* owner)
:Component(owner)
,mRadius(0.0f)
{
     
    
}

const Vector2& CircleComponent::GetCenter() const
{
     
    return mOwner->GetPosition();
}

float CircleComponent::GetRadius() const
{
     
    return mOwner->GetScale() * mRadius;
}

bool Intersect(const CircleComponent& a, const CircleComponent& b)
{
     
    // 计算距离的平方
    Vector2 diff = a.GetCenter() - b.GetCenter();
    float distSq = diff.LengthSq();

    // 计算半径之和,再平方
    float radiiSq = a.GetRadius() + b.GetRadius();
    radiiSq *= radiiSq;

    return distSq <= radiiSq;
}

我们需要把上面的 CircleCompoent 加到 Asterord 上:

    class CircleComponent* GetCircle() {
      return mCircle; }
private:
    class CircleComponent* mCircle;

在构造函数中初始化并设定半径:

, mCircle(new CircleComponent(this))
{
     
    // ...
    // 设置半径
    mCircle->SetRadius(40.0f);

飞船发射的激光需要检查和所有小行星之间的碰撞,因此可以在 Game 中添加 vector 保存 Asteroid

// Game.hpp
    void AddAsteroid(class Asteroid* ast);
    void RemoveAsteroid(class Asteroid* ast);
    std::vector<class Asteroid*>& GetAsteroids() {
      return mAsteroids; }
private:
    std::vector<class Asteroid*> mAsteroids;

实现:

void Game::AddAsteroid(Asteroid* ast)
{
     
    mAsteroids.emplace_back(ast);
}

void Game::RemoveAsteroid(Asteroid* ast)
{
     
    auto iter = std::find(mAsteroids.begin(),
        mAsteroids.end(), ast);
    if (iter != mAsteroids.end())
    {
     
        mAsteroids.erase(iter);
    }
}

Asteroid 中添加到 Game 中:

game->AddAsteroid(this);

在这之后,激光 Laser 类:

class Laser : public Actor
{
     
public:
    Laser(class Game* game);

    void UpdateActor(float deltaTime) override;
private:
    float mDeathTimer;
    
    class CircleComponent* mCircle;
    class SpriteComponent* sc;
    class MoveComponent* mc;
};

具体实现:

Laser::Laser(Game* game)
    :Actor(game)
    ,mDeathTimer(1.0f)
    ,sc(new SpriteComponent(this))
    ,mc(new MoveComponent(this))
    ,mCircle(new CircleComponent(this))
{
     
    sc->SetTexture(game->GetTexture("Assets/Laser.png"));

    mc->SetForwardSpeed(800.0f);

    mCircle->SetRadius(11.0f);
}

void Laser::UpdateActor(float deltaTime)
{
     
    // 超时的话,激光就已经衰退了
    mDeathTimer -= deltaTime;
    if (mDeathTimer <= 0.0f)
    {
     
        SetState(EDead);
    }
    else
    {
     
        for (auto ast : GetGame()->GetAsteroids())
        {
     
            if (Intersect(*mCircle, *(ast->GetCircle())))
            {
     
                // 消灭小行星
                SetState(EDead);
                ast->SetState(EDead);
                break;
            }
        }
    }
}

注意,这种碰撞检测对于 2D 可能是适用的,特别是像避开行星这种碰撞检测。可到了 3D 的时候,就不一定适用了。

最后:游戏逻辑

这个游戏很简单,就是可以按空格键发射激光摧毁小行星(碰撞消除)。

void Ship::ActorInput(const uint8_t* keyState)
{
     
    if (keyState[SDL_SCANCODE_SPACE] && mLaserCooldown <= 0.0f)
    {
     
        // 创建激光
        Laser* laser = new Laser(GetGame());
        laser->SetPosition(GetPosition());
        laser->SetRotation(GetRotation());

        // 重设冷却期
        mLaserCooldown = 0.5f;
    }
}

好了,大体就完成了。正常来讲,飞船不应该碰到行星的,碰到算输,但这样游戏难度太大了,不过实现的逻辑是一样的,只不过检测碰撞换成了行星和飞船而不再是行星与激光。

具体代码可以参考 Github

下一篇:估计会是游戏人工智能。

你可能感兴趣的:(游戏开发,游戏,游戏开发,C++,数学)