# 計算機程式專題 ## 個人分工 B12502166 劉昆泰 我個人主要是負責遊戲整體架構的程式以及遊戲邏輯、運行功能設計,主要寫了整體的遊戲框架、環境的建置,以及遊戲的整體邏輯與角色邏輯,這邊可以提一下,因為我們是使用clion在windows上進行編輯,不是像課程中教學使用code block或是vscode,在環境建置的時候,也是花費了不少心思在編輯cmakeList以及抓取動態函式庫,最後也有成功將環境建置出來並且公開倒github上給大家使用。 在這裡附上環境連結: https://github.com/Ktliu-Tyler/SDL_env_clion 遊戲連結: https://github.com/Ktliu-Tyler/GOBLIN_GAME ### 程式架構設計 關於程式的架構設計,我要求必須將檔案分類於include和src這兩個與main位於相同根目鱸的資料夾中,裡面放置我們的.h和.c檔案,方便後續進行debug時的程式跳轉,架構如下: Goblin_game ├── cmake_modules, CMakeLists.txt, SDL_lib<span style="color:blue"># CMake 配置文件</span> ├── imgs, musics, myFont, record <span style="color:blue"># 資料存放</span> ├── include │ ├── constants.h <span style="color:blue"># 定義常量</span> │ ├── Enemy.h <span style="color:blue"># 定義敵人相關的類和方法</span> │ ├── engine.h <span style="color:blue"># 定義遊戲引擎相關的類和方法</span> │ ├── menu.h <span style="color:blue"># 定義菜單相關的類和方法</span> │ ├── Player.h <span style="color:blue"># 定義玩家相關的類和方法</span> │ ├── playground.h <span style="color:blue"># 定義遊戲場景相關的類和方法</span> │ ├── test.h <span style="color:blue"># 定義測試相關的類和方法</span> │ └── tool.h <span style="color:blue"># 定義工具類和方法</span> ├── src │ ├── Enemy.cpp <span style="color:blue"># 實現敵人相關的功能</span> │ ├── engine.cpp <span style="color:blue"># 實現遊戲引擎相關的功能</span> │ ├── menu.cpp <span style="color:blue"># 實現菜單相關的功能</span> │ ├── Player.cpp <span style="color:blue"># 實現玩家相關的功能</span> │ ├── playground.cpp <span style="color:blue"># 實現遊戲場景相關的功能</span> │ ├── test.cpp <span style="color:blue"># 實現測試相關的功能 主要是我在除錯exe無法運行的問題</span> │ └── tool.cpp <span style="color:blue"># 實現工具相關的功能</span> └── main.cpp <span style="color:blue"># 主程序文件</span> <div style="page-break-after:always"></div> ### 遊戲參數設置 我將所有我們會用到的常數,包含會需要引用到的路徑名稱、圖片音訊位置都放在一個constant.h中,這樣使我們在進行debug的時候可以透過簡單的更改參數來進行調整,甚至是在更改角色圖片或式音效時都能透過這裡快速進行更改,不過這也是造成我們使用const和static較少的原因,因為我的程式習慣會將不更動的參數用define的方式寫成宏,並且可以當作全域變數使用,這樣在書寫時選字顏色比較容易區分,基本上所有的程式都會引用constant.h的常數,方便們進行管理,以下為constant.h的宣告程式以及其代表意義(圖片的話因為太長我就不全放了): ```cpp= // constant.h #ifndef CONSTANTS #define CONSTANTS #define TRUE 1 // 沒什麼用的宣告 只是比較習慣python的樣子 #define FALSE 0 // #define WINDOW_HEIGHT 720 // 螢幕高 #define WINDOW_WIDTH 1280 // 螢幕寬 #define WINDOW_M_HEIGHT 336 // 地面移動背景的高度x #define SCOREBOARD_X 20 // 記分板位置 #define SCOREBOARD_Y 10 #define SCOREBOARD_BET 50 // 記分板的間隔 #define FPS (16 * 60) // 幀率 單位有點錯,但這是目前運行最順暢的情況,詳細可見engine #define FRAME_TARGET_TIME (1000 / FPS) //寫在engine的update地方,計算deltatime #define PLAYER_W 100 //玩家大小 #define PLAYER_h 100 //玩家大小 // #define PLAYER_HIT_W 80 玩家碰撞大小 const int PLAYER_HIT_W = 80; // #define PLAYER_HIT_H 80 玩家碰撞大小 const int PLAYER_HIT_H = 80; // #define ENEMY_HIT_RATE 0.8 //原本define寫法也是互通的 const float ENEMY_HIT_RATE = 0.8; //這邊是因為我們都沒甚麼用到const所以在這裡用 #define PLAYER_SHOOT_T 30 // 玩家基礎射擊間隔 #define PLAYER_HP_MAX 20 // 玩家基礎血量 #define PLAYER_NP_MAX 40 // 玩家基礎能量 #define LEVEL_MAX 50 // 進度條最大值 #define PLAYER_SPEED 300 //玩家速度 #define BULLET_SPEED 300 //子彈速度 #define ENEMYNUMMAX 5 // 最大場上存在敵人數 #define ENEMYHP_MAX 10 // 敵人最高血量 #define ENEMPTYPE 3 // 敵人總類,每個遊戲模式各三個,其實後續會隨意更動 #define MENUID 0 // Menu編號 #define PLAYGROUNDID 1 // 遊戲編號 #define PLAYGROUNDID_RESTART 2 // 切換畫面的癲號 #define PLAYGROUNDID2 3 // 特殊遊戲模式編號 #define PLAYGROUNDID_RESTART2 4 // 切換特殊遊戲模式編號 #define BDSPEED -200 // 背景移動速度 #define BDTIME 10 // 背景移動週期 #define MENUBGM "../musics/BGM1.mp3" // 背景音樂 #define PLAYGROUNDBGM "../musics/bg.mp3"// 遊戲背景音樂 #define PLAYGROUNDBGM2 "../musics/GAMEOVER.mp3" // 遊戲背景音樂2 #define GAMEOVERBGM "../musics/BGM2.mp3" // 遊戲結束音樂 ``` ```cpp= #define SHOOT_SOUND "../musics/goblin/shoot.wav" //攻擊音樂 #define SHOOT1_SOUND "../musics/goblin/shoot1.wav" // 備用攻擊音樂 #define WALK_SOUND "../musics/goblin/walk.wav" // 走路聲音 #define HURT_SOUND "../musics/goblin/hurt.wav" // 受傷的聲音 #define GIRL_SOUND "../musics/girl.wav" // 女生受傷的聲音(百合) #define CLASSMATE_SOUND "../musics/classmate.wav" // 帥潮受傷聲音 #define BOMB_SOUND "../music/bomb.wav" //爆炸聲 #define GAMERECORD_FILE "../record/Gamerecoder.txt"// 遊戲紀錄的檔案位置 #define FONT "../myFont/Conther/Conther-2.ttf" // 字體位置 #define PLAYER_WALK_IMAGES {"../im...//哥布林走路 #define PLAYER_SHOOT_IMAGES { "... //射擊圖片 #define ARROW_NORM {"../imgs/arrow/arrow.png"}// 弓箭1圖片 #define ARROW_FAST {"../imgs/arrow/fastarrow.png"}//弓箭2圖片 #define ARROW_POISON {"../imgs/arrow/poisonarrow.png"}//弓箭3圖片 #define ARROW_POWER {"../imgs/arrow/powerarrow.png"}//弓箭4圖片 #define ENEMY1_IMAGES {"...//敵人圖片 #define ENEMY1_DIE {"...//敵人死亡圖片 #define MONSTER_IMAGES {"...//怪獸特瓦林塗圖片 #define HUMAN_IMAGES {"...//學生圖片 #define HUMAN2_IMAGES {"...//徐生圖片 #define HUMAN3_IMAGES {"...//學生圖片 #define EXPLODE_IMAGES {"...//爆炸圖片 #define SKAKE_HEAD {"...// 搖頭女孩圖片 #define STOP_PIC "../imgs/stop.png"// 停止圖片 #define CHANGE_BACKGROUND "../imgs/backgrounds/bg_change.png"// 黑幕位置 #define MENU_BACKGROUND "../imgs/backgrounds/menu.png" // Menu 介面背景 #define MENU_ANIME {"...//MENU的動畫圖片 #define PLAYGROUND_BACKGROUND {"... // 遊戲背景"../imgs/backgrounds/bg_1.jpg", #define PLAYGROUND_BACKGROUND_MOVE {"...// 移動地板圖片 // 這邊的圖片和音檔路徑引用,因為太多了就先省略 // 基本上就是名稱對應到的圖片或音訊路徑 #endif ``` 在這邊的常數設置基本上都跟const的用法類似,如果捨去掉改成設置一般的常數,很容易在編寫程式時不小心改動到定值而造成debug困難,其實define和const還是有很大的差別,一個是用預處理器先編好,但我這邊使用的功能比較類似而已,本來是有打算用define去做一些寫程式上的變化,像是把重複使用的function直接用define寫成段落貼入,不過最後時間不太夠就沒有額外的增加功能了。 下面的 static和 const 宣告在很多地方較分散,使用如下: | 宣告 | 功能 | 存放位置 | |-------------------------------------|-----|--------------------------------| | static GameRecorder *game_recorder |計分器 | engine.cpp, line 12 | | static int s = 60; |單次位移| Enemy.cpp, line 151 | | static menu *MenuPage = nullptr; |主畫面 | engine.cpp, line 14 | | static playground *PlayPage = nullptr; |遊戲畫面 | engine.cpp, line 15 | | static MusicPlayer *BGMmusic = nullptr; |音樂播放器 | SDL_bgi.cpp, line 42 | | static int fgWidth = static_cast<int>((static_cast<float>(value) / max_value) * w);|文字框寬度 | tool.cpp, line 31 | | static int n = ENEMYNUMMAX; | 敵人計數| playground.cpp, line 410 | | static int PAGE_ID = 0; |當前頁面紀錄| engine.cpp, line 21 | 這些static的變數能確保區域變數不受到外部存取的影響而改變其值,並且在重複呼叫該區塊時能保有其值,作為計數器使用,裡如上面倒數第二個static int n,若是拿掉static,會導致程式每次進入都重新宣告並賦值,而無法計算每次的當前敵人數而造成錯誤。 | 宣告方式 |功能 | 存放位置 | |---------|---------------|---------| | const std::vector\<std::string> *playbg = PLAYGROUND_BACKGROUND; |遊戲背景路徑 | engine.cpp, line 17 | | const std::vector\<std::string> Wimages = PLAYER_WALK_IMAGES; |角色圖片路徑 | Player.h, line 28 | | const std::vector\<std::string> playbgMove = PLAYGROUND_BACKGROUND_MOVE; |移動背景路徑 | engine.cpp, line 18 | const Uint8* keystate = (SDL_GetKeyboardState(NULL)); |讀取keyboard狀態 | engine.cpp, line 19 | | const std::vector\<std::string> Simages = PLAYER_SHOOT_IMAGES;|射擊圖片路徑 | Player.h, line 29 | | const int PLAYER_HIT_W = 80; |角色碰撞rect | constants.h, line 23 | | const std::vector\<std::string> Dimages = PLAYER_DIED_IMAGES; |角色死亡圖片路徑 | Player.h, line 30 | | const int PLAYER_HIT_H = 80; |角色碰撞rect | constants.h, line 25 | | const float ENEMY_HIT_RATE = 0.8; |敵人碰撞rect | constants.h, line 28 | const 的宣告能夠固定數值而不被他者更動,對於程式中的一些常數,用const宣告能避免錯誤更動,或是const指標,讓指標固定指向某一位址,如上面的keyboard輸入只能使用const,因為該function會回傳數值固定到該指標,因此必須用紙const指標來儲存,否則的話會因指標指向錯誤而無法讀取或是報錯。 <div style="page-break-after:always"></div> ### main.cpp / engine.cpp 這次遊戲引擎的架構我有用了一些小巧思,由於在剛開始設計的時候,考量到我們會有多個視窗切換,並且要讓組員之間容易分工,因此我思考可以利用模組化的方式,將每個介面分類成一個class,並且透過我們的engine程式來進行呼叫,這樣容易將各個頁面分配給組員進行編輯,也容易整合在一起。 首先,我們將整個程式運行的主架構分成以下部分,寫成engine的幾個function。而main.cpp程式只需要寫簡單的迴圈,並且呼叫engine的程式便可以運行。 ```cpp= //main.cpp int main(int argc, char *argv[]) { game_is_running = initialize_window(); setup(); while (game_is_running) { // SDL_Log("Game is running..."); process_input(); game_is_running = update(); render(); } destroy_window(); return 0; } ``` 可以注意到這邊有一個蠻重要的全域變數game_is_running,我們會持續監控此變數的狀態,以作為程式是否關閉的運行判斷。 ```cpp= //engine.h extern SDL_Window *mywindow; // 視窗指標 extern SDL_Renderer *renderer; // 選染器 extern int game_is_running; int initialize_window(); // 初始化 void setup(); // 設定各class void process_input(); // 處理輸入 int update(); // 更新角色資料 void render(); // 渲染 void change_page(); // 畫面切換 void destroy_window(); //清除視窗 ``` initialize_window()中會初始化SDL環境,和set_up差別在於一個是環境的初始化,一個是物件的初始化。並且分別都用SDL_Log對偵錯進行輸出,方便我們進行debug。還有這裡的destroy_window也有對所有建立好的指標進行記憶體釋放,優化我們的程式。 <div style="page-break-after:always"></div> 接下來這裡提一下我們的change_page以及一些engine邏輯 ```cpp= // engine.cpp 的宣告 static GameRecorder *game_recorder = nullptr; // 遊戲紀錄存檔模組 static menu *MenuPage = nullptr; // 菜單介面 static playground *PlayPage = nullptr; // 遊戲介面 static MusicPlayer *BGMmusic = nullptr; // 背景音樂播放器 const std::vector<std::string> playbg = PLAYGROUND_BACKGROUND; //背景圖的路徑 const std::vector<std::string> playbgMove = PLAYGROUND_BACKGROUND_MOVE; const Uint8* keystate = (SDL_GetKeyboardState(NULL)); //用來獲取鍵盤狀態 static int PAGE_ID = 0; // 用來判斷當前畫面 static int gametype = 1; //判斷遊戲模式 static int prePage_ID = 0; //跟PAGE_ID比較判斷畫面是否更改 static int last_frame_time = 0; //幀數紀錄時前一次的時間 static float delta_time = 0; // 用來存幀率對應的時間 ``` 上面介紹了這些參數的用途,應該很容易理解到,我主要想解釋的是我們切換畫面的方法,我使用Page_ID來進行頁面切換的判斷,對應執行該狀態介面的update、render、input_proccess,這樣有一個很大的優勢是,如果我今天想要新增新的介面,我只要確保此介面的class中,包含了我所需的render、input_proccess,和update,便可以直接在ID添加此介面,快速更新整個程式,並且,我也能透過這樣同時指運行一種介面的方式,來減少當前正在運行的資料量,達到更通順的運行。 ```cpp= void change_page() { if (PAGE_ID != prePage_ID) { prePage_ID = PAGE_ID; if(PAGE_ID == PLAYGROUNDID2) { PAGE_ID = PLAYGROUNDID; gametype =2; }else if(PAGE_ID == PLAYGROUNDID){ gametype = 1; } if (PAGE_ID == PLAYGROUNDID || PAGE_ID == PLAYGROUNDID_RESTART) { prePage_ID = PLAYGROUNDID; PAGE_ID = PLAYGROUNDID; SDL_Log("Play Game 1"); if(gametype == 1){ PlayPage = new playground(playbg[1], playbgMove[1] , renderer, game_recorder, BGMmusic); }else if (gametype == 2) { PlayPage = new playground2(playbg[0], playbgMove[0] , renderer, game_recorder, BGMmusic); } }else if (PAGE_ID == MENUID) { SDL_Log("MENU"); MenuPage = new menu(MENU_BACKGROUND, renderer , game_recorder, BGMmusic);}}} ``` 可以注意到我在裡面有兩個static變數,讓我判斷切換畫面,成功切換後直接更新playground或是menu物件,便能成功刷新或是切換畫面了。 <div style="page-break-after:always"></div> ## **class playground & playground2** ### 程式架構 *這區講的程式都在playgrdoun.h 和 playground.cpp中 此程式主要用於建立遊戲中的遊戲畫面page,這個class 用到了繼承、組合、複製建構子、解構子等功能,其中scoreboard為其子物件,而playground2又由他進行繼承並設置virtual函式調整了新增敵人的方法,player、enemy和bullet將其設為friend,可以取用這些class的private宣告。 ```cpp= // playground.h class playground { // 遊戲介面 class public: int update(float deltatime); int process_input(SDL_Event *event, const Uint8* keystate); void render(SDL_Renderer *renderer); void movebd(float deltatime);// 移動背景 void bdrender(); // 背景渲染 int gameSTOP(); // 遊戲結束時的判斷 void changebd(); //畫面切換的黑幕 playground(std::string path,std::string pathMove, SDL_Renderer* renderer, GameRecorder *recorder, MusicPlayer *music_player); // 物件建構子 playground(const playground & p); // 複製建構子 ~playground(); // 物件解構子 virtual void new_Enemy(); //新增敵人的程式 virtual void bullet_update(float deltatime); // 子彈更新程式 virtual void enemy_update(float deltatime); //敵人更新程式 virtual int gameOVER_ANIME(); // gameover的while迴圈 protected: float backgroundX; int timecounter = 0; // 痾好這個後來沒用到 int enemyNUM = 0; // 敵人當前數量 int chbgX = -300; // chang background的黑幕座標 int gameStart = false; // 判斷黑幕跑完沒 int gametype = 1; //判斷遊戲模式 SDL_Texture *background = nullptr; //背景圖 SDL_Texture *backgroundMove = nullptr;//背景圖 SDL_Texture *backgroundChange = nullptr;//黑幕圖 我用小畫家塗的 SDL_Renderer *renderer = nullptr; // 渲染氣 來自engine GameRecorder *recorder = nullptr; // 分數紀錄器 來自engine MusicPlayer *musicPlayer = nullptr; // 背景音樂 來自engine Player *player; // 玩家 Scoreboard scoreboard; // 子物件 記分板 std::vector<Bullet *> bullets; // 子彈指標陣列 std::vector<Enemy *> enemys; // 敵人指標陣列 }; ``` <div style="page-break-after:always"></div> ### 參數宣告 變數我主要都用 protected 進行宣告,以防不當的編寫意外更改導致難以除錯,同時也讓繼承其的 playground2 容易調整參數,而其他會被engine調用的程式我放在public,以利撰寫時直接調用。 * virtual的使用是因為後面繼承的特殊遊戲介面playground2 會需要更新不同的角色生成方式或是角色攻擊方式,這樣可以使後續程式能夠覆蓋這裡的函式內容。 ### 程式運行邏輯 運行程式時,由其上游的 engine 對應到這些 page 的基礎程式: 進入 playground -> process_input -> update -> render 而其中又分別對應 |程式| process_input | update | render | |----|--------------|--------------|---------------| |功能| 進行輸入讀取,有角色移動操作的輸入,子彈發射的輸入。遊戲畫面切換的輸入。| 遊戲資料更新,包含碰撞判斷、角色死亡判斷,以及分數紀錄更新等等 | 最重要的畫面渲染,會呼叫playground中,各個物件進行渲染,像是enemy、player、scoreboard等等 | |程式| 主要處理物件內的程式 | new_Enemy() bullet_update() enemy_update() movebd() | bdrender() 和一些角色 render()| 結構都依循著主架構的 engine,主要特別的是,遊戲有一個過場畫面的黑幕,因此設定了參數 gamestart 為 false ,待黑幕移動到指定位置後,將 gamestart 設為 true 才開始,還有在遊戲結束的畫面是於engine之中的while迴圈,自成一個架構的 gameOVER_ANIME() 其中等待使用者輸入後才決定跳轉至何畫面。接下來將根據個 function 依序介紹功能: #### 建構子 & 複製建構子 此 class 初始化需要提供 | path | pathMove | renderer | recorder |music_player | |---------------|--------------|---------------|-----|-----| | string | string | SDL_Renderer* |GameRecorder* (tool.h)| MusicPlayer* (tool.h) | | 背景圖片路徑 | 移動背景 | 渲染器 |分數紀錄器| 背景音樂播放器 | 這裡的 playground 是一個組合,包含了 scoreboard 的物件,在建立 playground 時一起初始化,用來記錄遊戲中的分數、血量、能量等等,並且進行記分板的畫面渲染以及遊戲狀態的紀錄,因此其將 playground 設為 friend 方便進行調用。 複製建構子程式如下 ```cpp= // playground.cpp playground::playground(const playground & c):scoreboard(c.renderer, font) { SDL_Log("Creating playground..."); this -> background = c.background; this -> backgroundMove = c.backgroundMove; this -> backgroundChange = c.backgroundChange; this -> renderer = c.renderer; this -> recorder = c.recorder; this -> musicPlayer = c.musicPlayer; this -> backgroundX = 0; // 計算畫面滾動的座標 this -> chbgX = -300; // 一開始畫面的位置 srand( time(NULL) ); // 設定random的參數隨機 font = TTF_OpenFont(FONT, 24); // 小字體 fontBIG = TTF_OpenFont(FONT, 100); // Gameover的字體 this->musicPlayer->stop(); // 在切換畫面時音樂停止 player = new Player(PLAYER_W+30, WINDOW_HEIGHT-WINDOW_M_HEIGHT, PLAYER_W,PLAYER_h, PLAYER_SPEED, renderer, WINDOW_HEIGHT,WINDOW_HEIGHT-WINDOW_M_HEIGHT); scoreboard.setHealth(PLAYER_HP_MAX);// 初始化scoreboard scoreboard.setNP(PLAYER_NP_MAX); scoreboard.setScore(0); scoreboard.setLevel(0); } ``` #### 繼承 & 組裝 前面有提過了playground 有一個繼承的 playground2 一樣在 playground.cpp中,而 playground 為一個組裝並包含scoreboard的物件,這邊使用組裝是因為scoreboard不需要被傳遞給engine,而是跟著playground一起被創建和delete,因此此直接把他當作playground的子物件來進行初始化也比較方便。而scoreboard又將playground設為friend方便調用,除此之外,內容中使用到的所有Enemy是由最初蝙蝠的enemy的class去進行繼承的,這樣方便我們在加入到enemy* 的 vector 中也能順利運行,並且方便修改整體的創建、判斷程式。 #### 特殊使用 (指標處理) 這邊想補充我在 enemy 和 bullet 新增的方法,也算是指標的使用,因為程式碼蠻長的就不貼上來,內容都在playground,cpp中可以找到new_enemy、enemy_update、bullet_update等,可以注意到我一開始設定的用來儲存的型別為指標陣列 (引用vector) ```cpp= // playground.h的class中 std::vector<Bullet *> bullets; std::vector<Enemy *> enemys; ``` <div style="page-break-after:always"></div> 不得不說 vector 是個很好用的東西,透過他我可以用 pushback 的方式輕鬆處理動態陣列,還有在 bullet 和 enemy 的碰撞判斷等等,直接調用物件指標,直接存取各物件的 hitrect 來進行判斷,當角色血量判斷死亡時,讓我們播放動畫及音效(這是我花很多心思的功能,後面會提到),並且可以用 erase 將物件從 vector 中去除,大幅提升了我在程式編寫上的效率,這邊的物件大多是使用指標宣告,方便對物件進行傳遞和取值,在物件被刪除時,我也都有使用delete將物件記憶體釋放。 還有一點是因為我們的敵人角色都是利用繼承的方式進行編寫,所以都能夠放入這邊的enemy指標陣列中,使得在切換不同種敵人生成時很方便。 #### polymorphism & virtual 這邊利用 virtual 達到 polymorphism 在繼承類別的實作,用意是讓特殊模式生成的敵人為其他繼承enemy類別的角色,方便我們對於不同種類的敵人記形客製化或是更改參數。 ```cpp= // 原virtual void playground::new_Enemy() { int n = ENEMYNUMMAX-enemyNUM; if (scoreboard.state == 'n') { for (int i = 0; i < n; i++) {// 判斷當前場上還缺幾個敵人就直接隨機生成 int enemyType = rand()%ENEMPTYPE; int hp = rand()%6+3; if(enemyType == 0) {//辨別隨機敵人對應的生成數字 enemys.push_back(new Enemy(rand()%WINDOW_WIDTH+WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15)+ (WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200,hp, renderer)); }else if (enemyType == 1) { enemys.push_back(new Human(rand()%WINDOW_WIDTH+WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15)+ (WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200, hp, renderer)); }else if (enemyType == 2) { enemys.push_back(new Human(rand()%WINDOW_WIDTH+WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15)+ (WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200, hp, renderer)); } enemyNUM++; } }else if (scoreboard.state == 'g'){// Boss生成 Monster *e = new Monster(rand()%WINDOW_WIDTH+WINDOW_WIDTH , WINDOW_HEIGHT/4+30, -100, 30, renderer); e->type = 'B'; enemys.push_back(e); enemyNUM = 0; scoreboard.state = 'B'; } } ``` 可以對比下面的內容,程式中都是使用random的方式來隨機生成敵人的血量,並根據血量去生成角色大小和攻擊力,主要差別就是使用的enemy繼承物件不一樣,而這也就是在初始化時對應到的動畫和音效不一樣。 ```cpp= // playground2 中的 new_Enemy() void playground2::new_Enemy() { static int n = ENEMYNUMMAX; n = ENEMYNUMMAX-enemyNUM;//計算要生成的敵人數量 for (int i = 0; i < n; i++) { int enemyType = rand()%ENEMPTYPE; int hp = rand()%10+1; if(enemyType == 0) {//判斷隨機數字對應 enemys.push_back(new Human3(rand()%WINDOW_WIDTH+ WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15) +(WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200,hp, renderer)); }else if (enemyType == 1) { enemys.push_back(new Human1(rand()%WINDOW_WIDTH+ WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15) +(WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200, hp, renderer)); }else if (enemyType == 2) { enemys.push_back(new Human2(rand()%WINDOW_WIDTH+WINDOW_WIDTH, (rand()%WINDOW_M_HEIGHT-30-hp*15)+ (WINDOW_HEIGHT-WINDOW_M_HEIGHT-100+hp*15), rand()%200, hp, renderer)); } // SDL_Log("Enemy born at %f, %f", enemys[i]->x, enemys[i]->y); enemyNUM++; } } ``` <!-- <div style="page-break-after:always"></div> --> #### enemy更新處理(指標處理) 在playground的許多函式中,我都使用到大量的指標來進行物件之間的交互,像是這邊對enemy的更新處理,我利用for迴圈遍歷指標陣列,並取出其記憶體位址針對其物件下的hitrect進行碰撞判斷,以及後續遊戲邏輯更新處理。 ```cpp= void playground::enemy_update(float deltatime) { for (auto it = enemys.begin(); it != enemys.end(); ) {//遍歷指標 Enemy* enemy = *it; enemy->kinetic(deltatime); for (auto bullet: bullets) { if(SDL_HasIntersection(bullet->rect, enemy->hitrect)//碰撞判斷 && !enemy->destroyed) { enemy->hurted(bullet->att);// 敵人受傷 bullet->destroyed = true; // 子彈消滅 scoreboard.getLevel(1); // level增加 enemy->animW->playSound(4);// 播放音效 if (bullet->type == 'p') { // 子彈種類判斷 enemy->speed =BDSPEED+10; // 敵人中毒減速 if(enemy->type == 'B') { // 判斷是否為BOSS enemy->speed = BDSPEED+120; //減速 SDL_Log("slow %d", enemy->speed); }// nextpage ``` ```cpp= } } }//這邊基本上和前面是相同的概念 if(SDL_HasIntersection(enemy->hitrect, player->hitrect) && !enemy->attacked && !enemy->destroyed) { scoreboard.getHurt(enemy->getAttack()); enemy->attacked = true; player->animD->playSound(5); SDL_Log("Hurt"); } int test = enemy->ifdied(); //判斷敵人死亡並進行分數與獎勵計算 if (enemy->destroyed && enemy->animD->finish) { enemy->destroy(); if(test == 1 && enemy->dieANIMcount == 0) { scoreboard.getScore(enemy->getAttack()); scoreboard.getNP(enemy->getAttack()/3+2); scoreboard.getLevel(enemy->getAttack()); enemy->animD->finish = false; enemy->state = 'D'; enemy->speed = BDSPEED; enemy->dieANIMcount++;//這是敵人死亡數的計數 //可以用來後續判斷是否要生成新敵人 } if(enemy->animD->finish) {//判斷敵人遭擊殺且死亡動畫播放完畢 delete enemy; enemyNUM--; if(enemy->type=='B') { scoreboard.state = 'n'; scoreboard.setLevel(0); enemyNUM = 0; } it = enemys.erase(it); } } else { ++it; } } } ``` #### 其他 其實還有很多內容,像是我的滾動背景設計,場景疊圖,物件碰撞程式(考量兩個rect重疊情況),角色死亡時的動畫呈現和清除的順序也是我花很多時間修整的地方,但礙於篇幅,這邊就不多贅述。 (程式碼一樣都位於playground中) <div style="page-break-after:always"></div> ## class Player & Bullet 接戲來就是遊戲的核心角色,我在設計上為了調用的方便,一樣幫只要是會渲染的物件添加move、kinetic、render,在playground中,這兩個式子會被添加到input_progress,update和render中,分別用來處理角色的速度方向控制、位移計算、畫面渲染,這部分的渲染會使用到我寫的Animation class,在動畫顯示上提供很大程度的自由性。 ### 程式架構 ```cpp= //player.cpp class Player {// 玩家 class public: friend class playground; Player(float x, float y, float width , float height, float speed, SDL_Renderer* renderer ,float yMax=WINDOW_HEIGHT,float yMin=0);//建構子 ~Player();//解構子 void init(float x, float y); //初始化,其實沒用到 因為角色遊戲結束會刪除 void render(SDL_Renderer* renderer); // 渲染 void move(char dir); // 改變移動方向 void kinetic(float dt); // 計算位置 void changeArrow(char type); //改變弓箭 private: SDL_Rect *rect = nullptr;//圖片rect SDL_Rect *hitrect = nullptr;//碰撞rect const std::vector<std::string> Wimages = PLAYER_WALK_IMAGES;// 走路圖片路徑 const std::vector<std::string> Simages = PLAYER_SHOOT_IMAGES;// 射擊圖片路徑 const std::vector<std::string> Dimages = PLAYER_DIED_IMAGES;// 死亡圖片路徑 Animation* animW = nullptr;//動畫 Animation* animS = nullptr;//動畫 Animation* animD = nullptr;//動畫 float x, y, vx=100, vy=100;//位置和速度 float speed;//速度 float width;//寬度 float height;//高度 float yMax;//向下最大位置 float yMin;//向上最大位置 char direction = 's'; // 角色方向 有's'靜止'u'向上'd'向下 char state = 'W'; // 角色狀態 char type = 'N'; // 角色弓箭模式 }; ``` #### 參數宣告 private主要是儲存player自己本身的動畫,碰撞位置、大小、方向等等,public主要為會被調用的函式,而將playgraound設為friend,讓其可以調用角色狀態,來判斷當前遊戲是否結束,還有scoreboard要如何顯示。 ```cpp= class Bullet { friend class Player; friend class playground; public: Bullet(float posx, float posy, char type, SDL_Renderer* renderer); ~Bullet(); void init(float x, float y);//初始化 void render(SDL_Renderer* renderer);//渲染 void kinetic(float dt);//計算位置 void destroy();// 子彈銷毀 void set_arrow(char t, SDL_Renderer* renderer); // 設定弓箭 private: Animation* anim = nullptr;//動畫 float x, y;// 位置 bool destroyed = false;//是否被摧毀 int att = 1; // 攻擊力 char type = 'N'; // 種類 有四種弓箭 int np = 0;//消耗的np SDL_Rect *rect;//圖片、碰撞 const std::vector<std::string> Nimages = ARROW_NORM;// 弓箭圖片 種類1 const std::vector<std::string> Fimages = ARROW_FAST;// 2 const std::vector<std::string> PNimages = ARROW_POISON;// 3 const std::vector<std::string> PRimages = ARROW_POWER;// 4 float vx=100, vy=100;// 速度 float speed=BULLET_SPEED; // 子彈速度 float width=40, height=40;// 大小 }; ``` #### 參數宣告 private主要是儲存Bullet自己本身的動畫,碰撞位置、大小等等,public主要為會被調用的函式,將playgraound和player設為friend,讓其可以調用弓箭狀態,來判斷敵人的扣血狀態和碰撞情況,還有角色的NP扣取量。 #### 建構子 對player和bullet進行初始化,都要提供位置大小等參數和速度,以定位腳色於我們的畫面上,而bullet發射的判斷是依附於player身上的座標進行移動。 #### Player 的移動 Player的移動主要要考慮當前動畫的幀率,透過在engine寄宿的delta time 去計算我們真正的位移量是多少,在加回原本的位置,能達到較順暢的移動。 ```cpp void Player::move(char dir) { direction = dir;// 更改當前的方向 } void Player::kinetic(float dt) { if(direction=='u') { y -= speed * vy/abs(vy)*dt;//考慮方向和deltatime }else if(direction=='d') { y += speed * vy/abs(vy)*dt; } if (y >= yMax-height) {//防止角色跑出陸地 y = yMax-height; } else if (y <= yMin-height) { y = yMin-height; } } ``` 在engine有時間的計算:並且把未達到deltatime的時間用while迴圈補齊,以達到每一幀都能等速的目的,不至於畫面忽快忽慢。 ```cpp= while (!SDL_TICKS_PASSED(SDL_GetTicks(), last_frame_time + FRAME_TARGET_TIME)); float delta_time = (SDL_GetTicks() - last_frame_time) / 1000.0f; last_frame_time = SDL_GetTicks(); ``` ## tool.cpp中的其他class和模組 在我的分類中,除了遊戲角色外的特舒功能,我都歸類到tool.cpp和tool.h中,包含Animation、Scoreboard、GameRecorder、MusicPlayer、healthbar之類的,以及一些非class的工具函式,例如:loadTexture、enderProgressBar、createTextTexture、renderText,還有顏色常數宣告(雖然大部分都沒用到) ```cpp= void renderProgressBar(int x, int y, int w, int h, int value, int max_value,SDL_Color fgColor, SDL_Color bgColor, SDL_Renderer *renderer); SDL_Texture* createTextTexture(const std::string& text, SDL_Color color, TTF_Font* font, SDL_Renderer* renderer); void renderText(const std::string& text, int x, int y, SDL_Color color, TTF_Font* font, SDL_Renderer* renderer, char type); ``` 例如這邊這邊的三個函式,主要都是在 tool 內部被調用,屬於最基層的工作函式,負責直接對介面或式檔案進行處理及渲染,邏輯的概念比較少。 <div style="page-break-after:always"></div> ## Animation 這是我寫得泛用性最廣的class,在整個程式裡,所有的動畫和音樂特效都是利用這個class進行操作的,基本上每個角色和敵人都有初始化自己的Animtion物件指標,用起來也很方便,還能彈性的調整動畫長度,算是我這裡面最得意的class之一。也是我花費很多時間進行編寫的成果。 ### 程式架構 ```cpp= class Animation { private: std::vector<SDL_Texture*> frames;// 用來儲存已經轉成texture的圖檔 int currentFrameIndex = 0;// 當前的圖片id int frameMax = 0;//最大圖片數 int timeCounter = 0; //計算每張圖的間隔時間 Mix_Chunk* sound = nullptr;//引入音效 void addAnimationFrame(const std::string& path, SDL_Renderer* renderer); //用來添加圖片 public: bool stop = false;//動畫是否暫停 int updateTime = 0;//更新時間 int finish = false;//動畫是否播完一輪了 char id;// 動畫名稱 void init(); // 動畫初始化 void playSound(int channel); //播放音效 Animation(char id, int updateTime, const std::vector<std::string>& imagePaths, SDL_Renderer* renderer,const std::string& path="");//建構子 SDL_Texture* getCurrentFrame();//讓外部獲取當前的動畫圖 ~Animation();//解構子 bool update(SDL_Renderer* renderer, SDL_Rect *rect);//更新動畫 }; ``` ### 參數宣告 pruvate將內建的時間計算、圖片列表、圖片讀取等包裝,使其不被外在class調用更改,同時public提供能夠調整更新時間、暫停動畫等等的參數,讓我們能對動畫的播放進行調整,像是弓箭在切換時射速改變,在player.cpp77行的change arrow就有使用updateTime的改變來讓快速弓箭發射變快。 ### 圖片動畫處理 為了讓新增動畫時更方便,不需要考慮圖片數量都能製成動畫,這裡的圖片輸入我特別用vector來進行圖片路徑的讀取,這樣可以讓多張圖片被讀取並轉成SDL_Texture* 的形別,並存入我們的frames中,同時還能計算最大圖片數,以利後續的動畫處理。 ```cpp= void Animation::addAnimationFrame(const std::string &path, SDL_Renderer* renderer) { SDL_Texture* frame = loadTexture(path.c_str(), renderer); this->frames.push_back(frame); this->frameMax++; } ``` 載入圖片是用以下程式讀入: (加上SDL_Log()有助於圖片錯誤時進行debug) ```cpp= SDL_Texture* loadTexture(const std::string& path, SDL_Renderer* renderer) { SDL_Texture* newTexture = nullptr; SDL_Surface* loadedSurface = IMG_Load(path.c_str()); if (loadedSurface == nullptr) { SDL_Log("Unable to load image %s", path.c_str()); } else { newTexture = SDL_CreateTextureFromSurface(renderer, loadedSurface); if (newTexture == nullptr) { SDL_Log("Unable to create texture from %s", IMG_GetError); } SDL_FreeSurface(loadedSurface); } return newTexture; } ``` 在建構式的地方將所有圖片讀入,以及讀入音訊: ```cpp= Animation::Animation(char id,int t, const std::vector<std::string>& imagePaths, SDL_Renderer* renderer, const std::string& soundPath){ this->id = id; this->updateTime = t; this->finish = true; if(soundPath !="") { this->sound = Mix_LoadWAV(soundPath.c_str()); } for (const std::string& path : imagePaths) { addAnimationFrame(path ,renderer); }//對圖片陣列遍歷並添加 } ``` 在使用時,利用getCurrentFrame()抓取圖片並回傳,同時對圖片的counter進行更新,counter是根據我一開始初始化時給的updateTime來進行判斷,因此updateTime愈高,這動畫就會播放得越慢可以和幀率之間進行配合調整。 ```cpp= SDL_Texture *Animation::getCurrentFrame() { timeCounter++; if (timeCounter >= updateTime && !stop) { currentFrameIndex++; if (currentFrameIndex >= frameMax-3) { currentFrameIndex = 0; finish = true; } timeCounter = 0; } return frames[currentFrameIndex]; } ``` 在我們的遊戲中,各個物件中都可以看到Animation的存在,從MENU的封面動畫到角色的射擊、移動、死亡動畫等等都是。只要呼叫 anim->update() 就能對rect的位置進行部分渲染。 ```cpp= bool Animation::update(SDL_Renderer* renderer, SDL_Rect *rect) { SDL_RenderCopy(renderer, getCurrentFrame(), nullptr, rect); return 0; } ``` ## Musicplayer 這個就比較簡單了,主要是用來處理背景音樂的程式,至於為甚麼音效和背景音樂要分開呢,主要是因為背景音樂讀入的是mp3檔案,而且使用背景音樂是不能進行混音的,他同時只能播放同一種音檔並循環播放,音次我將它和需要有即時性的音效分開,把音效加入Animation中配合動畫播出,而背景音樂主要在engine就被宣告,因為他是一個跨越頁面的音樂,需要考量到轉場間的音效不同。 而即時性的音效需要能夠堆疊混音,並且還要選擇分配channel,因此寫在Animation中,可以固定每個角色的channel。 ### 程式架構 ```cpp= class MusicPlayer { public: MusicPlayer(const std::vector<std::string>& Paths); void playMenu();//播主畫面聲音 void playGamining(int type=1);//播遊戲場景聲音 void playGameOver();//播遊戲結束聲音 void stop(); ~MusicPlayer(); private: Mix_Music *musicMenu = nullptr;//音檔 Mix_Music *musicPlayground = nullptr; Mix_Music *musicPlayground2 = nullptr; Mix_Music *musicGameOver = nullptr; Mix_Chunk *bombsound = nullptr; }; ``` ### 參數宣告 音檔都以指標型式儲存於class的private中,而會被外部調用的直接設為public ## healthBar 一個簡單的渲染功能,用來為敵人的血條分配大小與血量,並根據敵人當前血量反映出扣血的狀態,主要是由三個不同顏色的長方體堆疊渲染,行程外框底色以及血條,並控制血條部分的寬度。 ### 程式架構 ```cpp= class healthBar { public: healthBar(int max_hp,int w, int h, SDL_Renderer* renderer); void render(int x, int y,int width, int health, SDL_Renderer* renderer); private: int width, height; int max_health; SDL_Renderer *renderer; }; ``` ## 報告後額外心得分享 我自己一直都是很喜歡寫遊戲的人,過去也寫過pygame、godot、unity和一些c\++的小遊戲,這次期末專題剛好能夠有機會自由發揮,並讓我對於 c\++程式更加熟悉,這次花了相當多的時間投入在遊戲製作上,再加上我們組員人數只有兩個,即使早早就開始進行程式編寫,也一直調適到期末前才完成,很高興最後能看到在報告時大家有熱烈的反饋,讓我成就感十足。不過這次有點感覺奇怪的是,評分標準給的蠻晚的,其實最後趕工的時間,很多都是為了把原本設計的程式修出符合分數要求的程式,變得有點心煩意亂,畢竟原本完成的程式,又要更改很多地方,或又出現原本沒有的報錯之類的,不過所幸最後也是順利的完成了。還有報告編寫好麻煩呵呵,比起寫遊戲,報告對我來說根本就是一大負擔,不過把它上傳到hackmd和github上後又是另一種心情了啦,這學期真的收穫很多在c、c++中的特殊用法,課程的內容是真的紮實,很慶幸自己能選上這堂課。 P.S. 希望期末報告能高分! 附上hackmd連結: https://hackmd.io/@B-m0H4fLQ6WdHC924pQUYw/rk3EBw_Skl