控制臺版)
1. 為什么貪吃蛇是C入門游戲編程的最佳練手項目很多人學完C的基礎語法之后都會卡在一個尷尬的階段語法書翻完了循環(huán)、數(shù)組、函數(shù)、類都認識但讓自己從零寫一個能跑起來的東西腦子里一片空白。這個階段最需要的不是繼續(xù)啃更厚的書而是找一個規(guī)模適中、邏輯閉環(huán)、能立刻看到畫面反饋的小項目來練手。貪吃蛇就是這樣一個幾乎完美的選擇。我見過太多初學者一上來就想做五子棋、俄羅斯方塊甚至RPG小游戲結果卡在圖形界面或者復雜的碰撞邏輯上三天就放棄了。貪吃蛇的好處在于它的核心邏輯用一維數(shù)組或者二維數(shù)組就能描述清楚不需要任何圖形庫也能在控制臺里跑起來而且它的游戲循環(huán)、狀態(tài)更新、輸入處理這套骨架和后面做任何游戲都是相通的。你把這一個項目吃透等于把游戲編程最基礎的思維方式建立起來了。這篇文章我會帶你從零開始用C寫一個能在控制臺運行的貪吃蛇。不依賴任何第三方圖形庫純標準庫實現(xiàn)Windows和Linux都能編譯。我會把每一步的設計理由講清楚包括為什么用數(shù)組而不是鏈表、為什么用Sleep控制幀率、蛇身移動的坐標更新為什么要從尾到頭遍歷。這些細節(jié)才是真正決定你能不能獨立寫出第二個、第三個游戲的關鍵。適合誰看學過C基礎語法變量、循環(huán)、數(shù)組、函數(shù)、結構體或類但沒做過完整項目的朋友。如果你連for循環(huán)和數(shù)組下標都還不熟建議先把這些補上再回來。整篇內(nèi)容我會盡量用大白話解釋代碼給全注釋給足你照著敲一遍就能跑。2. 動手之前先把游戲循環(huán)這件事想明白2.1 游戲本質(zhì)上就是一個死循環(huán)初學者最容易忽略的一個概念是游戲程序的核心結構就是一個不停轉(zhuǎn)的循環(huán)。你看到的每一幀畫面、每一次蛇的移動、每一個按鍵的響應都是這個循環(huán)轉(zhuǎn)一圈的結果。這個循環(huán)通常叫游戲主循環(huán)Game Loop它的基本節(jié)奏是這樣的處理玩家輸入有沒有按鍵更新游戲狀態(tài)蛇往哪走、吃到食物沒有、撞墻沒有渲染畫面把當前狀態(tài)畫到屏幕上等待一小段時間控制游戲速度回到第1步貪吃蛇也不例外。你只要把這個循環(huán)搭起來剩下的就是往每個環(huán)節(jié)里填邏輯。很多人寫游戲?qū)懖幌氯ゾ褪且驗槟X子里沒有這個循環(huán)的框架東寫一塊西寫一塊最后邏輯全亂。2.2 控制臺游戲為什么還需要渲染有人會問控制臺不就是cout輸出文字嗎哪來的渲染其實控制臺游戲一樣需要渲染只不過渲染的方式是清屏重畫。每一幀我們都把整個游戲畫面重新打印一遍然后清掉上一幀的內(nèi)容視覺上就形成了動畫效果。在Windows下可以用system(cls)清屏Linux下用system(clear)。但system調(diào)用開銷比較大畫面會閃。更優(yōu)雅的做法是用ANSI轉(zhuǎn)義序列把光標移回左上角然后覆蓋重畫這樣幾乎不閃。不過為了代碼的跨平臺和簡單入門階段用system(cls)完全夠用等你熟練了再優(yōu)化。2.3 幀率控制為什么不能讓它全速跑如果你不加任何延時游戲循環(huán)會以CPU能跑的最快速度瘋狂轉(zhuǎn)圈蛇會瞬間飛出去撞墻玩家根本來不及反應。所以必須有一個節(jié)拍器來控制游戲速度。最簡單的方式是調(diào)用Sleep函數(shù)Windows下是Sleep(毫秒)Linux下是usleep(微秒)。這里有個經(jīng)驗值貪吃蛇的每幀間隔設在100到200毫秒之間比較舒服。太快了反應不過來太慢了蛇像蝸牛。我一般用150毫秒起步隨著分數(shù)增加逐漸減小間隔來提升難度這就是所謂的動態(tài)難度。// 跨平臺的延時封裝 #ifdef _WIN32 #include windows.h #define SLEEP(ms) Sleep(ms) #else #include unistd.h #define SLEEP(ms) usleep((ms) * 1000) #endif這段宏定義很實用寫一次以后所有項目都能復用。注意Linux的usleep參數(shù)是微秒所以要乘1000。3. 數(shù)據(jù)結構選型蛇身到底該用什么存3.1 數(shù)組方案和鏈表方案的取舍蛇身本質(zhì)上是一串有序的坐標點。存這串坐標最直覺的兩種選擇是數(shù)組和鏈表。很多教程一上來就用鏈表理由是蛇頭插入、蛇尾刪除鏈表是O(1)。這話理論上沒錯但對初學者來說鏈表方案有幾個坑需要手寫節(jié)點結構體和指針操作容易出內(nèi)存泄漏調(diào)試的時候沒法直接print整個蛇身得遍歷代碼量翻倍邏輯復雜度上升而數(shù)組方案呢蛇的最大長度是有限的受地圖大小限制我們完全可以用一個固定大小的數(shù)組來存蛇身坐標用一個變量記錄當前長度。蛇移動的時候本質(zhì)上是頭部長出一個新坐標尾部刪掉一個舊坐標用數(shù)組實現(xiàn)就是整體挪動或者用環(huán)形緩沖。我的建議是入門階段用數(shù)組等你能熟練寫出數(shù)組版本之后再自己改寫成鏈表版本作為進階練習。這樣你能親身體會兩種數(shù)據(jù)結構的差異比直接抄鏈表代碼收獲大得多。3.2 坐標點的表示蛇身的每個節(jié)點就是一個(x, y)坐標。用一個簡單的結構體表示最清晰struct Point { int x; int y; };然后蛇身就是一個Point數(shù)組const int MAX_SNAKE_LEN 100; Point snake[MAX_SNAKE_LEN]; int snakeLength 3; // 初始長度3節(jié)這里MAX_SNAKE_LEN取100是個保守值實際能到多長取決于地圖大小。如果你的地圖是20x20那蛇最長也就400節(jié)取100夠用了。想更嚴謹可以取地圖格子總數(shù)。3.3 蛇身移動的坐標更新順序這是整個項目里最容易寫錯的一個點我必須重點講。蛇移動的時候你不能簡單地讓每一節(jié)都往前挪一格因為那樣會互相覆蓋。正確的做法是從尾巴開始每一節(jié)都變成它前面那一節(jié)的坐標// 從尾到頭遍歷每節(jié)追上它前面一節(jié) for (int i snakeLength - 1; i 0; i--) { snake[i] snake[i - 1]; } // 然后單獨更新蛇頭 snake[0].x dirX; snake[0].y dirY;為什么必須從尾到頭因為如果你從頭到尾遍歷snake[1]會先被改成snake[0]的新位置然后snake[2]又去追snake[1]結果整條蛇會瞬間塌縮成一團。從尾到頭就不會有這個問題因為每一節(jié)讀取的前一節(jié)都還沒被修改。這個細節(jié)我在第一次寫的時候踩過坑蛇會莫名其妙變成一坨排查了半天才反應過來是遍歷順序的問題。你如果自己寫一定要記住這個順序。4. 地圖、食物與碰撞檢測的完整實現(xiàn)4.1 地圖邊界怎么畫控制臺地圖最簡單的畫法是用字符拼一個矩形框。比如20列寬、15行高四周用#圍起來中間是空格。每次渲染的時候先打印上邊框然后逐行打印左右邊框和中間內(nèi)容最后打印下邊框。const int MAP_WIDTH 20; const int MAP_HEIGHT 15; void drawMap() { // 上邊框 for (int i 0; i MAP_WIDTH 2; i) cout #; cout endl; // 中間行 for (int y 0; y MAP_HEIGHT; y) { cout #; for (int x 0; x MAP_WIDTH; x) { // 這里根據(jù)(x,y)位置畫蛇、食物或空格 } cout # endl; } // 下邊框 for (int i 0; i MAP_WIDTH 2; i) cout #; cout endl; }注意邊框占了額外兩列所以實際游戲區(qū)域是MAP_WIDTH x MAP_HEIGHT坐標范圍是x: 0~MAP_WIDTH-1y: 0~MAP_HEIGHT-1。這個坐標系一定要統(tǒng)一否則碰撞檢測會出錯。4.2 食物生成怎么避免生成到蛇身上食物就是一個隨機坐標。但你不能隨便隨機因為有可能隨機到蛇身占據(jù)的格子上那樣食物就藏在蛇里了玩家永遠吃不到。所以生成食物之后必須檢查這個位置是否和蛇身重疊如果重疊就重新生成。Point generateFood() { Point food; bool valid false; while (!valid) { food.x rand() % MAP_WIDTH; food.y rand() % MAP_HEIGHT; valid true; for (int i 0; i snakeLength; i) { if (snake[i].x food.x snake[i].y food.y) { valid false; break; } } } return food; }這個while循環(huán)理論上可能轉(zhuǎn)很多次但實際游戲中蛇身占比很小通常一兩次就成功了。如果地圖特別小、蛇特別長可以考慮用收集所有空格再隨機選一個的方式效率更穩(wěn)定。4.3 碰撞檢測的三種情況碰撞檢測要處理三種情況缺一不可碰撞類型判斷條件處理方式撞墻蛇頭x0 或 xMAP_WIDTH 或 y0 或 yMAP_HEIGHT游戲結束撞自己蛇頭坐標和任意一節(jié)蛇身坐標相同游戲結束吃到食物蛇頭坐標和食物坐標相同蛇身加長分數(shù)1重新生成食物撞自己的檢測有個細節(jié)只需要檢測蛇頭是否和蛇身的第1節(jié)到第N-1節(jié)重疊不用檢測第0節(jié)蛇頭自己。因為蛇頭不可能和自己重疊。另外如果蛇的長度是1那永遠不會撞到自己。bool checkCollision() { // 撞墻 if (snake[0].x 0 || snake[0].x MAP_WIDTH || snake[0].y 0 || snake[0].y MAP_HEIGHT) { return true; } // 撞自己從第1節(jié)開始檢查 for (int i 1; i snakeLength; i) { if (snake[0].x snake[i].x snake[0].y snake[i].y) { return true; } } return false; }4.4 吃到食物后蛇怎么變長吃到食物后蛇要加長一節(jié)。最簡單的做法是不刪除尾巴。正常移動時我們會把尾巴那節(jié)丟掉通過坐標覆蓋實現(xiàn)吃到食物時就不丟這樣蛇自然就長了一節(jié)。具體實現(xiàn)上可以在移動邏輯里加一個判斷如果這一幀吃到了食物就跳過尾巴的更新同時snakeLength。但要注意數(shù)組越界snakeLength不能超過MAX_SNAKE_LEN。// 移動邏輯簡化版 for (int i snakeLength - 1; i 0; i--) { snake[i] snake[i - 1]; } snake[0].x dirX; snake[0].y dirY; // 如果吃到食物 if (snake[0].x food.x snake[0].y food.y) { if (snakeLength MAX_SNAKE_LEN) { snakeLength; // 加長尾巴不動 } score; food generateFood(); }這里有個小技巧加長的時候新加的那節(jié)坐標其實是上一幀尾巴的坐標因為我們沒有更新它。視覺上看起來就是蛇尾長出了一節(jié)很自然。5. 輸入處理與方向控制的那些坑5.1 控制臺怎么讀取方向鍵這是控制臺游戲最煩人的地方之一。Windows下用_getch()可以讀取按鍵方向鍵是特殊鍵需要讀兩次第一次返回0或224第二次才返回具體的方向碼上72、下80、左75、右77。Linux下則要用termios把終端設成非規(guī)范模式然后讀getchar()方向鍵是ESC序列\(zhòng)033[A等。為了代碼簡潔我建議入門階段用WASD或者方向鍵的字母替代方案W上、S下、A左、D右。這樣跨平臺簡單一行cin或者getchar就能搞定。等你把游戲邏輯跑通了再花時間研究方向鍵的讀取。char input; if (_kbhit()) { // Windows下檢測是否有按鍵 input _getch(); switch (input) { case w: case W: if (dirY ! 1) { dirX 0; dirY -1; } break; case s: case S: if (dirY ! -1) { dirX 0; dirY 1; } break; case a: case A: if (dirX ! 1) { dirX -1; dirY 0; } break; case d: case D: if (dirX ! -1) { dirX 1; dirY 0; } break; } }5.2 為什么不能直接反向注意上面代碼里的if判斷當蛇正在向右走時不能直接按左鍵讓它反向。因為反向會導致蛇頭瞬間撞上自己的脖子游戲立刻結束。這不是bug是規(guī)則。所以每次改變方向前都要檢查新方向是否和當前方向相反相反就忽略這次輸入。這個判斷看起來簡單但如果你忘了寫玩家一按反向鍵就死體驗極差。我見過不少新手版本都有這個問題。5.3 輸入緩沖和按鍵丟失控制臺輸入還有個坑如果你用cin input它是阻塞的游戲循環(huán)會卡在等待輸入上蛇就不動了。所以必須用非阻塞的方式檢測按鍵。Windows下_kbhit()配合_getch()是標準做法。Linux下需要自己封裝稍微麻煩一點。另一個問題是按鍵緩沖。如果玩家快速按了好幾個方向鍵這些按鍵會排隊游戲會依次處理可能導致蛇走出奇怪的路徑。解決辦法是每幀只處理一個按鍵或者干脆清空緩沖區(qū)只取最后一個。入門階段每幀處理一個就夠了。6. 把代碼組裝起來完整可運行版本6.1 完整代碼結構下面是一個完整的、可以直接編譯運行的版本。我把它拆成幾個函數(shù)每個函數(shù)職責單一方便你理解和修改。#include iostream #include cstdlib #include ctime #ifdef _WIN32 #include windows.h #include conio.h #define CLEAR cls #define SLEEP(ms) Sleep(ms) #else #include unistd.h #include termios.h #include fcntl.h #define CLEAR clear #define SLEEP(ms) usleep((ms) * 1000) #endif using namespace std; const int MAP_WIDTH 20; const int MAP_HEIGHT 15; const int MAX_SNAKE_LEN 200; struct Point { int x, y; }; Point snake[MAX_SNAKE_LEN]; int snakeLength; Point food; int dirX, dirY; int score; bool gameOver; void initGame() { snakeLength 3; snake[0] {MAP_WIDTH / 2, MAP_HEIGHT / 2}; snake[1] {MAP_WIDTH / 2 - 1, MAP_HEIGHT / 2}; snake[2] {MAP_WIDTH / 2 - 2, MAP_HEIGHT / 2}; dirX 1; dirY 0; score 0; gameOver false; srand((unsigned)time(nullptr)); // 生成第一個食物 food.x rand() % MAP_WIDTH; food.y rand() % MAP_HEIGHT; } void draw() { system(CLEAR); // 上邊框 for (int i 0; i MAP_WIDTH 2; i) cout #; cout \n; for (int y 0; y MAP_HEIGHT; y) { cout #; for (int x 0; x MAP_WIDTH; x) { bool printed false; // 先判斷是不是蛇頭 if (snake[0].x x snake[0].y y) { cout O; printed true; } // 再判斷是不是蛇身 if (!printed) { for (int i 1; i snakeLength; i) { if (snake[i].x x snake[i].y y) { cout o; printed true; break; } } } // 再判斷是不是食物 if (!printed food.x x food.y y) { cout *; printed true; } if (!printed) cout ; } cout #\n; } // 下邊框 for (int i 0; i MAP_WIDTH 2; i) cout #; cout \n; cout Score: score Length: snakeLength \n; cout Controls: WASD to move, Q to quit\n; } void handleInput() { #ifdef _WIN32 if (_kbhit()) { char c _getch(); switch (c) { case w: case W: if (dirY ! 1) { dirX 0; dirY -1; } break; case s: case S: if (dirY ! -1) { dirX 0; dirY 1; } break; case a: case A: if (dirX ! 1) { dirX -1; dirY 0; } break; case d: case D: if (dirX ! -1) { dirX 1; dirY 0; } break; case q: case Q: gameOver true; break; } } #else // Linux非阻塞讀取 struct termios oldt, newt; tcgetattr(STDIN_FILENO, oldt); newt oldt; newt.c_lflag ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, newt); int oldf fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); char c getchar(); tcsetattr(STDIN_FILENO, TCSANOW, oldt); fcntl(STDIN_FILENO, F_SETFL, oldf); switch (c) { case w: case W: if (dirY ! 1) { dirX 0; dirY -1; } break; case s: case S: if (dirY ! -1) { dirX 0; dirY 1; } break; case a: case A: if (dirX ! 1) { dirX -1; dirY 0; } break; case d: case D: if (dirX ! -1) { dirX 1; dirY 0; } break; case q: case Q: gameOver true; break; } #endif } void update() { // 移動蛇身 for (int i snakeLength - 1; i 0; i--) { snake[i] snake[i - 1]; } snake[0].x dirX; snake[0].y dirY; // 撞墻檢測 if (snake[0].x 0 || snake[0].x MAP_WIDTH || snake[0].y 0 || snake[0].y MAP_HEIGHT) { gameOver true; return; } // 撞自己檢測 for (int i 1; i snakeLength; i) { if (snake[0].x snake[i].x snake[0].y snake[i].y) { gameOver true; return; } } // 吃食物 if (snake[0].x food.x snake[0].y food.y) { if (snakeLength MAX_SNAKE_LEN) snakeLength; score; // 重新生成食物 bool valid false; while (!valid) { food.x rand() % MAP_WIDTH; food.y rand() % MAP_HEIGHT; valid true; for (int i 0; i snakeLength; i) { if (snake[i].x food.x snake[i].y food.y) { valid false; break; } } } } } int main() { initGame(); while (!gameOver) { draw(); handleInput(); update(); SLEEP(150); } cout \nGame Over! Final Score: score \n; return 0; }6.2 編譯和運行Windows下用MinGW或者Visual Studio直接編譯g snake.cpp -o snake.exe snake.exeLinux下g snake.cpp -o snake ./snake注意Linux下需要鏈接termios但它是標準庫的一部分通常不用額外加-l參數(shù)。如果編譯報錯找不到usleep加上#include unistd.h即可。6.3 實測中容易遇到的問題問題一畫面閃爍嚴重。這是system(cls)的通病。解決辦法是用ANSI轉(zhuǎn)義序列\(zhòng)033[H把光標移回左上角然后逐行覆蓋。但Windows老版本控制臺默認不支持ANSI需要先調(diào)用SetConsoleMode開啟虛擬終端處理。入門階段忍一忍或者用雙緩沖先拼好整個畫面字符串再一次性輸出。問題二蛇移動速度不均勻。因為Sleep只是大致延時實際幀間隔會有波動。如果追求精確可以用chrono庫計算實際經(jīng)過的時間做固定時間步長更新。但對貪吃蛇來說沒必要Sleep夠用。問題三Linux下按鍵讀取有問題。上面的Linux代碼每次調(diào)用handleInput都會修改終端屬性效率很低。更好的做法是在main開始時設置一次結束時恢復。我這里為了代碼緊湊才這么寫你實際項目里應該優(yōu)化。問題四食物生成死循環(huán)。如果蛇長得幾乎填滿地圖while(!valid)可能轉(zhuǎn)很久。解決辦法是維護一個空格列表或者當蛇長超過地圖一半時直接判定勝利。7. 從能跑到好用幾個值得做的優(yōu)化方向7.1 動態(tài)難度越吃越快固定150毫秒的幀間隔玩久了會膩。一個簡單的改進是讓延時隨分數(shù)遞減int delay 150 - score * 3; if (delay 50) delay 50; // 下限50毫秒 SLEEP(delay);這樣每吃一個食物蛇就快一點難度自然上升。下限設50毫秒是因為再快人眼和手指就跟不上了。7.2 分數(shù)持久化把最高分存到文件每次游戲結束把最高分寫到一個文本文件里下次啟動讀出來顯示。這是最簡單的持久化練習用fstream幾行就能搞定int loadHighScore() { ifstream fin(highscore.txt); int hs 0; if (fin) fin hs; return hs; } void saveHighScore(int hs) { ofstream fout(highscore.txt); fout hs; }這個功能雖小但能讓你第一次體會數(shù)據(jù)在程序關閉后還能保留的感覺是邁向更復雜項目的必經(jīng)一步。7.3 用類重構把游戲封裝成一個對象上面的代碼全是全局變量和自由函數(shù)規(guī)模小的時候沒問題但一旦要擴展比如加暫停、加菜單、加多種模式全局變量就會變成災難。這時候應該用類把游戲狀態(tài)封裝起來class SnakeGame { private: Point snake[MAX_SNAKE_LEN]; int snakeLength; Point food; int dirX, dirY; int score; bool gameOver; public: void init(); void run(); void draw(); void handleInput(); void update(); };重構之后main函數(shù)就變成三行創(chuàng)建對象、初始化、運行。這就是面向?qū)ο缶幊痰膬r值——把復雜度關在類里面外面看到的接口干干凈凈。我強烈建議你在跑通數(shù)組版本之后親手做一次這個重構收獲會非常大。7.4 用鏈表替代數(shù)組進階練習等你對類版本也熟練了可以嘗試把蛇身從數(shù)組換成鏈表。每個節(jié)點存坐標和指向下一節(jié)的指針蛇頭插入新節(jié)點蛇尾刪除節(jié)點。這樣蛇的長度就不受MAX_SNAKE_LEN限制了。但要注意內(nèi)存管理刪除節(jié)點時記得delete否則會泄漏。這個練習的意義不在于鏈表本身而在于讓你體會同一個問題用不同數(shù)據(jù)結構解決的思維差異。做完之后你對數(shù)據(jù)結構的理解會上一個臺階。8. 我在教別人寫貪吃蛇時總結的幾條經(jīng)驗帶過幾個朋友入門之后我發(fā)現(xiàn)大家踩的坑高度集中這里統(tǒng)一說一下。第一不要一上來就追求圖形界面。很多人覺得控制臺太丑非要用EasyX或者SDL。結果光配置環(huán)境就花了兩天游戲邏輯一行沒寫熱情先耗光了??刂婆_版本雖然樸素但它讓你專注在邏輯上這才是入門階段最該練的。等你控制臺版本寫得滾瓜爛熟換圖形庫只是換個渲染函數(shù)的事。第二先把偽代碼寫出來再敲鍵盤。我見過太多人打開IDE就開始寫寫到一半發(fā)現(xiàn)邏輯不對刪了重來。正確做法是先在紙上或者注釋里把游戲循環(huán)、移動邏輯、碰撞檢測的步驟寫清楚確認沒問題了再翻譯成代碼。這個習慣能幫你省下大量調(diào)試時間。第三善用調(diào)試輸出。蛇的坐標、方向、長度這些關鍵變量在開發(fā)階段可以每幀打印出來看。別嫌亂等你確認邏輯正確了再刪掉。我調(diào)試移動邏輯的時候就是靠打印每一節(jié)的坐標才發(fā)現(xiàn)遍歷順序?qū)懛戳恕5谒倪吔鐥l件一定要測。蛇貼著墻走會怎樣蛇長度是1的時候撞自己檢測會不會越界食物生成在蛇頭正前方會怎樣這些邊界情況你不主動測它們就會在某個時刻突然冒出來讓你措手不及。養(yǎng)成寫完一個功能就想想邊界的習慣受益終身。第五代碼風格從第一天就注意。變量命名要有意義snakeLength而不是n函數(shù)職責要單一draw就只管畫別在里面改狀態(tài)常量用const而不是魔法數(shù)字。這些習慣在項目小的時候看不出差別但項目一大就是天壤之別。最后說一句貪吃蛇這個項目真正的價值不在于游戲本身而在于它逼著你把C的數(shù)組、循環(huán)、函數(shù)、結構體、輸入輸出這些基礎知識串起來用一遍。你寫完這個項目再回頭看那些語法書會發(fā)現(xiàn)以前覺得抽象的概念突然都具體了。這就是項目驅(qū)動學習的威力。寫完控制臺版本之后你可以試著加個開始菜單、加個暫停功能、加個排行榜每一個小擴展都是一次真實的工程練習。