由于代码渐渐多了起来,现在参考实现已经上传到 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,构成了爱因斯坦描述的四维空间。但是,处于三维空间的我们是无法画出这玩意的。换句话说,可视化只能最多是三维的。
向量的运算和算术运算有一些差异。有一些简单的概念可以稍微复习一下:
注意,向量的加减法几何意义上遵循的是三角形法则。
减法的原则可以简单记为:起点相同,指向被减。(起点不同?那就平移到相同~)
在乒乓球游戏那篇,就已经给出了二维向量的简单实现:
// Vector2 结构体仅存储 x 和 y 坐标
struct Vector2
{
float x;
float y;
};
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=ax⋅bx+ay⋅by
数量积和三角函数中的余弦 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。点积的物理意义是物体做功的大小,数学上则代表一个向量在另外一个向量上进行的垂直投影。
叉积主要是用来确定平面的,可以理解为一个平面的法向量(对应的一组概念:切向量),即运算得到的向量垂直于原来的两个向量。叉积的运算结果是一个向量,而且一般的符号就是我们使用的乘法符号 × \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∣∣∣∣∣∣=(aybz−azby,azbx−axbz,axby−aybx)
注意,其实就是把对应的分量(那一列)去掉,然后交叉相乘再相减。这个过程就是线性代数中三阶行列式降维(拆分成二维行列式)的计算。注意,中间(第二个)的时候,是反过来的(乘了-1,原本交叉相乘再相减是 a x b z − a z b x a_x b_z - a_z b_x axbz−azbx,乘-1,就反过来了)。这是由于外面还乘了一个 ( − 1 ) i + j (-1)^{i + j} (−1)i+j。
物理大厦在牛顿之后被重构了两次(好像是吧~):爱因斯坦的相对论,在牛顿基础上进一步揭示力的本质,阐释了相对的时空观,直接把人类对于宇宙的认知提高了一个新的等级;普朗克开始的量子力学,在微观领域又把人类推入不确定的深渊。直到今天,人类还是不知道上帝到底掷不掷骰子。
由于我们身处的世界里体验不到爱因斯坦相对论中的宏观高速,达不到量子力学的微观层次。我们通常还是活在牛顿力学适用的宏观低速的世界中。要在游戏里模拟真实世界,构建一个符合牛顿力学的世界是非常有必要的,这也意味着不得不回顾一下牛顿的力学。
(此处不打算写复杂的力学,就只讨论简单的直线运动,不讨论圆周运动,旋转的力学问题)
牛顿的第一定律是所谓的惯性定律,简单来讲,物体的重量大小决定了物体的惯性大小;有公式的是牛顿的第二定律,揭示出力和加速度的关系:
F ⃗ = m ⋅ a ⃗ \vec F=m\cdot \vec a F=m⋅a
力 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)
上面的 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);
现在就可以通过键盘移动飞船了:
在之前的 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=(x1−x2)2+(y1−y2)2
为了实现碰撞检测,我们自然可以封装一个 CircleCompoent
来完成上述计算。
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
下一篇:估计会是游戏人工智能。