Xcode與C++之遊戲開發:Pong遊戲

Xcode與C++之遊戲開發:Pong遊戲

接下來在前兩天遊戲骨架的基礎上實現一個經典的乒乓球(Pong)遊戲。遊戲是這樣的,一個球在屏幕上移動,玩家控制球拍來擊打球。可以說乒乓球遊戲是遊戲開發者的 “Hello World” 項目。

繪製遊戲中的物體
乒乓球球拍我們使用矩形來表示。繪製填充矩形,SDL 有 SDL_RenderFillRect 函數,它接受一個 SDL_Rect 代表的填充矩形,而矩形顏色由當前的繪圖顏色決定。換句說,現在我們不改變繪圖顏色,它默認就會使用那個幸福浪漫的蒂芙尼藍。當然,顏色一摸一樣,我們是看不見的。爲了看得出矩形,將它修改成藍色。

在 Game::GenerateOutput() 交換緩衝區之前寫入:

  // 設置繪製顏色
  SDL_SetRenderDrawColor(mRenderer, 0, 0, 255, 255);
1
2
要繪製矩形,需要指定一個 SDL_Rect 結構體。這個結構體有4個參數,左上角的點x/y座標,還有矩形的高和寬度。在絕大多數的圖形庫中,包括 SDL,窗口左上角的點的座標是(0, 0),x正半軸是向右,y正半軸是向下的(和數學上的相反)。

假設我們要在屏幕上方繪製矩形作爲遊戲的牆,可以使用下面的 SDL_Rect 的定義:

  // 頂部牆的參數
  SDL_Rect wall {
    0, // 左上 x 座標
    0, // 左上 y 座標
    1024, // 寬度
    kThickness // 高度
  };
1
2
3
4
5
6
7
寬度被硬編碼成1024,一般來說這需要根據窗口尺寸自動修正,後面會考慮修正這個問題。thickness 是一個 const int 常量,被設置成15,這是爲了方便調整牆的厚度。

C++ 不推薦使用 #define 宏預定義,更推薦使用 const

在頭文件聲明之後加入:

const int kThickness = 15;
1
最後,用 SDL_RenderFillRect 繪製矩形,傳入 SDL_Rect 指針:

SDL_RenderFillRect(mRenderer, &wall);
1
這樣遊戲窗口上面多了一道牆,類似的,可以畫出底部的牆和右邊的牆,只需要通過改變 SDL_Rect 的參數。比如,下面那道牆左上角的 y 座標就是 768 - thickness(因爲窗口初始化時高度被初始化爲768)。

  // 繪製底部牆
  wall.y = 768 - kThickness;
  SDL_RenderFillRect(mRenderer, &wall);
  
  // 繪製右邊的牆
  wall = {
    1024 - kThickness,
    0,
    kThickness,
    1024
  };
  SDL_RenderFillRect(mRenderer, &wall);
1
2
3
4
5
6
7
8
9
10
11
12
左邊的牆呢?留着給玩家打乒乓球。

牆硬編碼可能問題不大,乒乓球球和球拍就不能硬編碼了。隨着遊戲循環,球拍是要會動的。實際遊戲編程中,球和球拍應該抽象成類,但是現在我們姑且先用變量代替一下硬編碼。

首先,先定義一個 Vector2 結構體來存儲 x 和 y 座標。這個定義放到 Game.hpp 之中:

// Vector2 結構體僅存儲 x 和 y 座標
struct Vector2
{
  float x;
  float y;
};
1
2
3
4
5
6
接着添加兩個 Vector2 的成員變量到 Game 類中,一個作爲球拍 mPaddlePos,一個作爲球 mBallPos。

  // 球拍位置
  Vector2 mPaddlePos;
  // 球的位置
  Vector2 mBallPos;
1
2
3
4
之後在初始化時 Game::Initialize() 賦予一個合理的初始值。

  // 初始化球拍和球的座標
  mPaddlePos.x = 10.0f;
  mPaddlePos.y = 768.0f / 2.0f;
  mBallPos.x = 1024.0f / 2.0f;
  mBallPos.y = 768.0f / 2.0f;
1
2
3
4
5
x和y在這裏是中心座標,不符合 SDL_Rect 的要求。因此要先把x和y座標轉換成左上角的點。

  // 繪製球拍
  SDL_Rect paddle {
    static_cast<int>(mPaddlePos.x),
    static_cast<int>(mPaddlePos.y - kPaddleH / 2),
    kThickness,
    static_cast<int>(kPaddleH)
  };
  SDL_RenderFillRect(mRenderer, &paddle);
  
  // 繪製球
  SDL_Rect ball {
    static_cast<int>(mBallPos.x - kThickness / 2),
    static_cast<int>(mBallPos.y - kThickness / 2),
    kThickness,
    kThickness
  };
  SDL_RenderFillRect(mRenderer, &ball);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
其中,kPaddleH 也是個 const int 常量,用來控制球拍的長度(可控制難度),將其設置爲 100.0f。

const float kPaddleH = 100.0f;
1
這樣乒乓球的所需要物體,或者說簡陋的素材就完成了。看看效果。



接下來,就要把靜態變成動態,完成遊戲循環和交互邏輯的編寫。

更新遊戲
在編寫遊戲循環的時候,有一點是值得特別關注的,就是時間。務必記住一點,遊戲循環不是連續的,它只是刷新頻率帶來的錯覺。遊戲中的時間和現實的物理時間也未必一樣。這是開發的時候需要特別注意的。

由時間引出了刷新頻率的問題,假如最早的遊戲在 8MHz 的處理器下運行,有這樣的代碼:

// x 座標更新五個像素
enemy.mPosition.x += 5;
1
2
那麼,如果在 16MHz 的處理器上運行呢?刷新的速率翻了一倍,會導致遊戲速度快了一倍。遊戲的難度陡然上升,完全有可能將困難的挑戰變成不可能。爲了解決這個問題,遊戲將使用增量時間(delta time)——上一幀到現在的時間流逝長度。

要採用增量時間,就需要轉換思考的角度,從每幀移動的像素數量轉變成每秒移動的像素數量。所以,我們把速度調整爲150每秒,採用增量時間,這樣更加靈活:

// 每秒更新150個像素
enemy.mPosition.x += 150 * deltaTime;
1
2
現在,代碼與幀速無關了,無論是 30 FPS 還是 60 FPS,都能照常運行。實際遊戲編程時,大部分都需要採用增量時間。

爲了計算增量時間,SDL 提供了 SDL_GetTicks 函數返回從 SDL_Init 調用以來的毫秒數。通過存儲上一幀的 SDL_GetTicks 的結果在成員變量裏,就可以使用現在的值來計算增量時間。

首先,聲明一個 mTicksCount 作爲 Game 的成員變量,並在構造函數中初始化爲0。

在 Game.hpp 的 Game 中增加代碼:

  // 記錄運行時間
  Uint32 mTicksCount;
1
2
構造函數中初始化爲0:

Game::Game()
:mWindow(nullptr)
,mRenderer(nullptr)
,mIsRunning(true)
,mTicksCount(0)
{
  
}
1
2
3
4
5
6
7
8
使用 SDL_GetTicks 的代碼在 Game::UpdateGame 中實現:

void Game::UpdateGame()
{
  // 增量時間是上一幀到現在的時間差
  // (轉換成秒)
  float deltaTime = (SDL_GetTicks() - mTicksCount) / 1000.0f;
  
  // 更新運行時間(爲下一幀)
  mTicksCount = SDL_GetTicks();
}
1
2
3
4
5
6
7
8
9
仔細想想,這代碼還是有問題的。有一些依賴於物理的遊戲(比如平臺跳躍類),行爲會根據幀速率而有所不同。最簡單的解決方案就是限制速度,強制遊戲循環需要等到一個增量時間。例如,目標幀速 60 FPS,一幀完成僅需要 15ms,則強制附加 1.6ms。

SDL 已經爲我們提供了限制幀速的方法。例如,要限制至少幀與幀之間間隔 16ms,可以在 UpdateGame 一開始附加上代碼:

void Game::UpdateGame()
{
  // 等到與上一幀間隔 16ms
  while (!SDL_TICKS_PASSED(SDL_GetTicks(), mTicksCount + 16))
    ;
  
  // 增量時間是上一幀到現在的時間差
  // (轉換成秒)
  float deltaTime = (SDL_GetTicks() - mTicksCount) / 1000.0f;
  
  // 更新運行時間(爲下一幀)
  mTicksCount = SDL_GetTicks();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
除了關注最短間隔,我們也應該關注最長間隔。例如,在調試的時候中斷了遊戲,那麼一段時間之後恢復運行,遊戲將產生一個突躍。爲了解決這個問題,只需要設定一個增量時間的最大值。

  // 固定增量時間最大值
  if (deltaTime > 0.05f)
  {
    deltaTime = 0.05f;
  }
1
2
3
4
5
球拍位置
在乒乓球遊戲中,我們可以通過鍵盤輸入 W 向上移動球拍,S 向下移動球拍。可以定義一個 mPaddleDir 表示方向,-1 表示球拍向上(負y),1 表示球拍向下移動。

  // 球拍方向
  int mPaddleDir;
1
2
記得在構造函數中初始化,

Game::Game()
:mWindow(nullptr)
,mRenderer(nullptr)
,mIsRunning(true)
,mTicksCount(0)
,mPaddleDir(0)
{
  
}
1
2
3
4
5
6
7
8
9
控制遊戲的位置通過鍵盤輸入,因此代碼放到 ProcessInput 中。

  // 通過 W/S 更新球拍位置
  mPaddleDir = 0;
  if (state[SDL_SCANCODE_W])
  {
    mPaddleDir -= 1;
  }
  if (state[SDL_SCANCODE_S])
  {
    mPaddleDir += 1;
  }
1
2
3
4
5
6
7
8
9
10
注意這裏是加上和減去,而不是採取直接賦值爲 -1 和 1,因爲這樣才能確保玩家同時按兩個鍵時mPaddleDir 是0。接下來,在 UpdateGame 中根據增量時間和方向,更新球拍位置。除此之外,還需要防止球拍超出窗口,必須限制在有效範圍內。

  // 根據方向更新球拍位置
  if (mPaddleDir != 0)
  {
    mPaddlePos.y += mPaddleDir * 300.0f * deltaTime;
    
    // 確保球拍不能移出窗口
    if (mPaddlePos.y < (kPaddleH / 2.0f + kThickness))
    {
      mPaddlePos.y = kPaddleH / 2.0f + kThickness;
    } else if (mPaddlePos.y > (768.0 - kPaddleH / 2.0f - kThickness))
    {
      mPaddlePos.y = 768.0f - kPaddleH / 2.0f - kThickness;
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
在這裏,速度是300個像素每秒。現在,可以編譯運行一下看看了。

更新球的位置
更新球的位置就有一點點複雜了。球拍僅僅在一維(y)上運動,而乒乓球可是在二維平面上運動。另外,球碰到牆和球拍會反彈,從而改變它的方向。因此,需要使用球的速度和對它進行碰撞檢測。

由於球在二維平面上運動,因此對速度的表示採用的是矢量分解。添加一個 Vector2 成員變量 mBallVel,初始化成 (-200.0f, 235.0f),代表一開始球向x負方向以每秒200像素移動,同時向下每秒移動235像素。換句話說,向左下移動。

  // 球的速度
  Vector2 mBallVel;
1
2
在 Game::Initialize() 中進行初始化:

  mBallVel = {-200.0f, 235.0f};
1
接下來,需要編寫能夠將球從牆上彈回的代碼。用於確定球是否與牆壁碰撞的代碼類似於檢查球拍是否在屏幕外。如果球的y位置小於或等於球的高度,則球與頂壁碰撞。一個問題是,碰撞之後怎麼運動。

應該不難想到,假如球從上到下,那麼碰到底部的牆將反彈向上。類型的,碰撞到右邊,則反彈向左。基於矢量的原理,僅僅只需要在相應的分量上乘以 -1,改變方向即可。

  // 球是否和頂部牆相碰
  if (mBallPos.y <= kThickness && mBallVel.y < 0.0f)
  {
    mBallVel.y *= -1;
  }
  else if (mBallPos.y >= (768 - kThickness) && mBallVel.y > 0.0f)
  {
    // 球和底部牆相碰
    mBallVel.y *= -1;
  }
1
2
3
4
5
6
7
8
9
10
對速度進行校驗是有必要的,否則可能導致球粘在牆上。

球與牆的碰撞稍微簡單一點,與球拍的碰撞就比較複雜了。

如果球在球拍的正上方和正下方,這就是沒碰撞了;(球的y座標和球拍的y座標差值大於球拍的高度的一半)
檢查球的x座標是否和球拍一致,並且球不處於遠離狀態。
  // 是否和球拍相交
  float diff = mPaddlePos.y - mBallPos.y;
  // 取絕對值
  diff = (diff > 0.0f) ? diff : -diff;
  if (
      // y分量差距足夠小
      diff <= kPaddleH / 2.0f &&
      // 球拍的x範圍內
      mBallPos.x <= 25.0f && mBallPos.x >= 20.0f &&
      // 球正向左運動
      mBallVel.x < 0.0f
    )
  {
    mBallVel.x *= -1.0f;
  }
  // 如果球出了窗口,結束遊戲
  else if (mBallPos.x <= 0.0f)
  {
    mIsRunning = false;
  }
  // 如果球碰到右邊的牆,則反彈
  else if(mBallPos.x >= (1024.0f - kThickness) && mBallVel.x > 0.0f)
  {
    mBallVel.x *= -1.0f;
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
最終

這樣,遊戲屆的“Hello World”的Pong遊戲就編寫完成了,最後,改一下窗口標題,換成 Pong,就可以了。但是,我們把 Pong 遊戲的各個遊戲對象都放到了 Game 中,這不利於擴展。這就是我們之後將進一步討論的遊戲對象。



下一篇:Xcode與C++之遊戲開發:Pong遊戲
--------------------- 
作者:穀雨の夢 
來源:CSDN 
原文:https://blog.csdn.net/guyu2019/article/details/87551008 
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章