形數(shù)組、狀態(tài)機(jī)與定點重繪實戰(zhàn))
上一篇文章我們把控制臺貪吃蛇最基礎(chǔ)的版本跑通了蛇能動、食物能刷、撞墻能結(jié)束。但那個版本距離“一個能玩的游戲”還差得遠(yuǎn)。如果你真把它拿給朋友試玩多半會被吐槽三件事按鍵卡頓、畫面閃爍、蛇稍微一長就找不著頭在哪。這篇就接著上一篇往下寫目標(biāo)很實在把控制臺貪吃蛇從“能跑”升級到“能玩”順便把代碼結(jié)構(gòu)理順讓后面再加功能不會越改越亂。文中代碼我用C/C混合風(fēng)格寫Windows控制臺環(huán)境可以直接用拿到Linux下把conio.h相關(guān)部分換成termios也能跑通。1. 從第一版到第二版這次改造的核心思路1.1 第一版留下的三個坑第一版貪吃蛇的代碼結(jié)構(gòu)估計寫過的人都差不多一個while(true)主循環(huán)循環(huán)里讀鍵盤、更新蛇頭坐標(biāo)、用system(cls)清屏再重新畫整個地圖。優(yōu)點是邏輯直白兩三屏代碼就能把“蛇會動、能吃食物、會撞墻”講完作為第一篇的收尾綽綽有余。但如果你照著那個版本繼續(xù)加功能很快會碰到三堵墻。第一堵墻是畫面閃爍。system(cls)每次調(diào)用都會把整個控制臺緩沖區(qū)清掉然后你再重新輸出幾十上百個字符。操作一多光標(biāo)來回跳動屏幕上就像按了頻閃燈一樣。蛇短的時候還好蛇身長到二三十節(jié)每一幀要重繪太多字符閃爍感會明顯到讓人不想玩。第二堵墻是速度控制不準(zhǔn)。第一版最常用Sleep(interval)控制每一幀的間隔但這個寫法有個隱藏問題Sleep只保證“至少等這么多毫秒”它不計入游戲邏輯本身的耗時。你移動蛇、繪制畫面、讀取按鍵都要花時間把這些時間加上去實際每幀間隔會比設(shè)定的interval長而且長短不穩(wěn)定。結(jié)果就是蛇的速度忽快忽慢吃食物加速的邏輯也沒法做準(zhǔn)。第三堵墻是數(shù)據(jù)結(jié)構(gòu)太脆弱。很多人習(xí)慣用std::vector存蛇身每次移動先insert新頭再pop_back刪尾巴。功能上沒問題但所有邏輯全堆在主循環(huán)里代碼長了之后哪里動了蛇身、哪里改了狀態(tài)根本理不清。第二篇我不想急著上多復(fù)雜的算法先把這些問題解決掉后面寫計分、菜單、難度才會輕松。1.2 第二版的目標(biāo)與功能清單這次改造我給自己列了五條硬性目標(biāo)蛇身用固定數(shù)組加環(huán)形隊列思想來維護(hù)徹底擺脫頻繁的內(nèi)存操作放棄system(cls)改成光標(biāo)定位加定點重繪消滅閃爍引入固定時間戳計時替代Sleep讓速度控制可預(yù)期加入菜單、暫停、結(jié)束、再來一局用狀態(tài)機(jī)管理游戲流程增加計分和難度遞進(jìn)讓游戲有“越玩越難”的反饋感。這五條做完這個項目就不再是“教學(xué)Demo”了它更像一個可以拿來消遣、可以繼續(xù)擴(kuò)展的小成品。下面我按模塊拆開講每段都會給出關(guān)鍵代碼和踩坑說明。2. 蛇身數(shù)據(jù)結(jié)構(gòu)環(huán)形數(shù)組與隊列思想2.1 為什么蛇的移動本質(zhì)上是個隊列貪吃蛇每走一步做的事情可以拆成兩步看先算出新蛇頭要去的位置如果新位置不是食物就把尾巴那一節(jié)刪掉。這正好符合隊列“頭進(jìn)尾出”的模型。用std::deque當(dāng)然能做但控制臺項目里我更推薦用定長數(shù)組加head/tail兩個下標(biāo)來模擬理由有兩個第一你不需要引入STL容器純C環(huán)境也能跑方便理解底層原理第二環(huán)形數(shù)組的訪問是O(1)的畫蛇身也只需要從尾巴遍歷到頭部代碼非常直觀。先定義一下蛇的結(jié)構(gòu)體const int MAX_BODY 500; // 地圖最多能容納的蛇身節(jié)數(shù) struct Snake { int x[MAX_BODY]; int y[MAX_BODY]; int head; // 蛇頭所在數(shù)組下標(biāo) int tail; // 蛇尾所在數(shù)組下標(biāo) int len; // 當(dāng)前蛇身長度 };初始化時我們讓蛇頭朝右從(startX, startY)開始往左擺兩節(jié)身體。這里有個容易繞暈的細(xì)節(jié)環(huán)形數(shù)組的“頭”和“尾”不是固定在0號下標(biāo)而是靠head和tail兩個游標(biāo)動態(tài)移動。void initSnake(Snake s, int startX, int startY) { // 蛇身三節(jié)從右往左排 s.x[0] startX 2; s.y[0] startY; // 最后一節(jié)尾巴 s.x[1] startX 1; s.y[1] startY; s.x[2] startX; s.y[2] startY; // 蛇頭 s.tail 0; s.head 2; s.len 3; }2.2 入隊與出隊新頭進(jìn)尾巴出移動時新蛇頭應(yīng)該寫到緊挨著當(dāng)前head的下一個位置也就是(head 1) % MAX_BODY。取模的意思是數(shù)組下標(biāo)到末尾時就回繞到開頭形成一個環(huán)。這里還要區(qū)分兩種情況吃到食物時蛇身要增長尾巴不動沒吃到食物時尾巴跟著前進(jìn)一格保持長度不變。void moveSnake(Snake s, int newX, int newY, bool grow) { int newHead (s.head 1) % MAX_BODY; s.x[newHead] newX; s.y[newHead] newY; s.head newHead; if (!grow) { // 沒吃東西尾巴往前挪一格 s.tail (s.tail 1) % MAX_BODY; } else { s.len; } }這段代碼是整條游戲的“發(fā)動機(jī)”邏輯很簡單但寫的時候必須注意順序。我之前犯過把tail更新放在head更新之前的錯結(jié)果新頭寫入還沒完成尾巴先動了下一次畫蛇身時坐標(biāo)就對不上畫面直接亂掉。2.3 畫蛇身從尾巴遍歷到頭部繪制蛇身時從tail開始依次訪問(tail 1) % MAX_BODY、(tail 2) % MAX_BODY……直到遍歷完len個節(jié)點。每次取模都保證下標(biāo)不會越界。void drawSnake(const Snake s) { int idx s.tail; for (int i 0; i s.len; i) { gotoxy(s.x[idx], s.y[idx]); SetConsoleTextAttribute(handle, 10); // 綠色 std::cout ■; idx (idx 1) % MAX_BODY; } }注意用■這類全角字符時一個字符占兩個字節(jié)控制臺光標(biāo)定位必須落在正確坐標(biāo)上否則畫出來的蛇身會出現(xiàn)錯位。建議把地圖邊界和蛇身都按“字符數(shù)”來算坐標(biāo)不要和字節(jié)數(shù)混淆。2.4 判斷蛇頭是否撞到自己自碰檢測是貪吃蛇最容易寫錯的地方。直覺做法是遍歷整條蛇身看新頭坐標(biāo)是否和某一節(jié)重疊。但有一個特殊情況如果新頭正好落在尾巴即將移開的位置那不算撞到自己因為這一節(jié)馬上要被出隊了。所以正確的遍歷范圍是“除了尾巴以外的所有節(jié)點”或者換個思路先判斷沒吃到食物時尾巴會先移走再檢查新頭是否與剩余身體重疊。bool hitSelf(const Snake s, int nx, int ny, bool grow) { int count grow ? s.len : s.len - 1; int idx s.tail; for (int i 0; i count; i) { if (s.x[idx] nx s.y[idx] ny) { return true; } idx (idx 1) % MAX_BODY; } return false; }這個“是否排除尾巴”的細(xì)節(jié)很多人在第一次寫時都會漏。如果你發(fā)現(xiàn)蛇總是莫名其妙死在“明明尾巴要離開的位置”多半就是這里的問題。3. 輸入控制與渲染優(yōu)化手感提升的關(guān)鍵3.1 方向鍵的讀取兩個字節(jié)的坑Windows控制臺下方向鍵不是普通字符按下后會返回兩個字節(jié)第一個字節(jié)是224或0第二個才是真正表示方向的掃描碼。只用_getch()讀一次會拿到224直接判斷方向必然失敗。我見過不少人卡在這個問題上一兩個小時其實處理起來很簡單int readDirection() { if (!_kbhit()) return -1; int key _getch(); if (key 224 || key 0) { key _getch(); // 再讀一次拿到真實按鍵 } switch (key) { case 72: return DIR_UP; case 80: return DIR_DOWN; case 75: return DIR_LEFT; case 77: return DIR_RIGHT; default: return -1; } }如果你在Linux下做移植方向鍵序列是\x1B[A這樣的轉(zhuǎn)義序列也是三個字節(jié)原理相通。3.2 防反轉(zhuǎn)用異或判斷正反方向貪吃蛇最經(jīng)典的限制不能直接掉頭。當(dāng)前正在向右走的時候玩家按左鍵應(yīng)該被忽略。如果不用任何技巧你得寫四個if判斷然后又臭又長。這里有個很聰明的位運算方法。給方向編號enum Direction { DIR_UP 0, // 上 DIR_DOWN 1, // 下 DIR_LEFT 2, // 左 DIR_RIGHT 3 // 右 };巧合的是0 ^ 1 11 ^ 1 02 ^ 1 33 ^ 1 2。也就是說上下相對、左右相對恰好可以用dir ^ 1表示“反方向”。判斷時可以這么寫int newDir readDirection(); if (newDir ! -1 newDir ! (curDir ^ 1)) { curDir newDir; }這個寫法不管當(dāng)前方向是哪個只要新方向是當(dāng)前方向的反向就一定等于curDir ^ 1直接忽略即可。代碼整潔邏輯也不容易出錯。3.3 擺脫system(cls)光標(biāo)定位與定點重繪第二版最重要的一步是把整屏重繪改成局點更新。既然蛇每次移動只影響三個位置新蛇頭、舊蛇尾、可能變化的新食物那理論上每幀只需要在這些位置上做擦除和重畫。Windows下定位光標(biāo)用SetConsoleCursorPositionvoid gotoxy(int x, int y) { COORD pos; pos.X x; pos.Y y; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); }再配合一個隱藏光標(biāo)函數(shù)視覺效果會干凈很多void hideCursor() { CONSOLE_CURSOR_INFO cci; GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci); cci.bVisible FALSE; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci); }有了這兩個函數(shù)移動蛇的時候就可以精準(zhǔn)操作// 每次update之后 // 1. 畫新蛇頭 gotoxy(newX, newY); SetConsoleTextAttribute(handle, 10); std::cout ■; // 2. 如果沒吃到食物擦掉舊尾巴 if (!ate) { gotoxy(oldTailX, oldTailY); std::cout ; // 兩個空格相當(dāng)于擦除 } // 3. 如果吃到食物畫新食物位置變了 if (ate) { drawFood(); }這種方法下畫面不再全書刷新閃爍幾乎消失蛇長到50節(jié)也不會有視覺卡頓。3.4 固定時間戳讓速度控制真正可控用Sleep(interval)控制幀率的最大問題是它只負(fù)責(zé)“睡”不負(fù)責(zé)“校準(zhǔn)”。假設(shè)每幀游戲邏輯要跑5毫秒你設(shè)置Sleep(100)實際每幀間隔就是105毫秒誤差會累積蛇會越走越快或者忽快忽慢。解決辦法是記錄上一次更新的時間戳每次循環(huán)檢查當(dāng)前時間是否已經(jīng)超過固定間隔DWORD lastTick GetTickCount(); int interval 150; // 毫秒 while (running) { DWORD now GetTickCount(); if (now - lastTick interval) { updateGame(); lastTick now; } render(); // 每幀都渲染保證按鍵響應(yīng)及時 }注意render()不能放在if塊里。因為按鍵響應(yīng)需要在空閑時間也能被處理如果把渲染也卡進(jìn)定時邏輯按一下方向鍵可能要等一個間隔周期才有反應(yīng)手感非常差。暫停功能實現(xiàn)時記得做一件事恢復(fù)暫停的瞬間把lastTick重新賦值為GetTickCount()。否則暫停期間流逝的時間會被算進(jìn)去一恢復(fù)游戲蛇就往前猛跳一大步。4. 游戲狀態(tài)機(jī)與完整項目骨架4.1 為什么要把游戲狀態(tài)拆開第一版把所有邏輯寫在一個循環(huán)里菜單、暫停、結(jié)束全都塞進(jìn)去會讓代碼變成一團(tuán)亂麻。更好的做法是抽象出一個“狀態(tài)機(jī)”讓程序知道自己當(dāng)前在哪個界面并根據(jù)狀態(tài)決定該執(zhí)行哪些邏輯。定義狀態(tài)enum GameState { STATE_MENU 0, // 主菜單 STATE_RUN, // 游戲中 STATE_PAUSE, // 暫停 STATE_OVER // 游戲結(jié)束 };主循環(huán)變成這樣int main() { initGame(); while (true) { switch (state) { case STATE_MENU: handleMenu(); break; case STATE_RUN: handleRun(); break; case STATE_PAUSE: handlePause(); break; case STATE_OVER: handleOver(); break; } } return 0; }每次只處理當(dāng)前狀態(tài)下的按鍵和繪制邏輯邊界非常清晰。菜單里按空格進(jìn)入游戲游戲中按空格暫停暫停中按空格恢復(fù)結(jié)束后按R重新開始。狀態(tài)切換通過簡單的賦值實現(xiàn)。4.2 菜單、暫停、結(jié)束三個輔助界面菜單界面可以畫一個簡單的標(biāo)題和選項void drawMenu() { gotoxy(MAP_WIDTH / 2 - 5, MAP_HEIGHT / 2 - 2); std::cout 貪吃蛇 v2.0; gotoxy(MAP_WIDTH / 2 - 8, MAP_HEIGHT / 2); std::cout 1. 簡單 2. 普通 3. 困難; gotoxy(MAP_WIDTH / 2 - 6, MAP_HEIGHT / 2 1); std::cout 空格鍵開始; }這里順便把難度選擇做了按下1、2、3分別把interval設(shè)為200、150、100對應(yīng)慢、中、快三檔。菜單事件處理放在handleMenu()里讀取按鍵并修改全局狀態(tài)。暫停界面不用動地圖只需要在畫面中央顯示“暫停中”幾個字然后等待空格鍵。注意暫停時必須保留當(dāng)前的地圖畫面不能清除否則玩家恢復(fù)后看不到蛇在哪里。結(jié)束界面可以顯示最終得分并提示按R重新開始。這里有兩個細(xì)節(jié)一個是重新開始前要重新初始化所有全局變量另一個是必須把lastTick也重置否則新游戲第一幀會立刻觸發(fā)一次更新。4.3 計分與難度遞進(jìn)計分規(guī)則很簡單每吃一個食物加10分。在右側(cè)畫一個記分板用剛才的gotoxy精確繪制。這里有個容易忽略的地方記分板區(qū)域和游戲地圖區(qū)域最好分開管理比如地圖占左邊60列記分板占右邊20列避免坐標(biāo)重疊。難度遞進(jìn)我用“分?jǐn)?shù)每增加50分移動間隔減少10毫秒”的規(guī)則同時設(shè)置一個下限防止蛇快得完全沒法操作if (score 0 score % 50 0 interval 60) { interval - 10; }實際體驗下來60毫秒是普通玩家能接受的極限再快就需要用到“預(yù)測下一個按鍵”的技巧了。5. 實操過程搭一個能玩的控制臺版本5.1 關(guān)鍵變量和初始化流程為了保證所有函數(shù)都能訪問到需要的數(shù)據(jù)我建議用全局變量組織核心數(shù)據(jù)數(shù)量不多可控Snake snake; int foodX, foodY; int score; int curDir; int interval; GameState state; DWORD lastTick; HANDLE handle;初始化時依次做獲取控制臺句柄、隱藏光標(biāo)、初始化蛇的位置、隨機(jī)生成第一個食物、設(shè)置初始狀態(tài)為菜單。隨機(jī)函數(shù)記得用srand((unsigned)time(NULL))給種子否則每次運行食物位置都一樣。5.2 游戲循環(huán)的update實現(xiàn)handleRun()是核心函數(shù)里面做三件事讀方向、判斷是否該更新、更新畫面。void handleRun() { int newDir readDirection(); if (newDir ! -1 newDir ! (curDir ^ 1)) { curDir newDir; } DWORD now GetTickCount(); if (now - lastTick (DWORD)interval) { return; // 時間沒到只處理按鍵 } lastTick now; // 計算新頭位置 int dx[4] {0, 0, -1, 1}; int dy[4] {-1, 1, 0, 0}; int nx snake.x[snake.head] dx[curDir]; int ny snake.y[snake.head] dy[curDir]; // 撞墻檢測 if (nx 0 || nx MAP_WIDTH || ny 0 || ny MAP_HEIGHT) { state STATE_OVER; drawOver(); return; } bool eat (nx foodX ny foodY); // 自碰檢測 if (hitSelf(snake, nx, ny, eat)) { state STATE_OVER; drawOver(); return; } // 記錄舊尾巴坐標(biāo)用于擦除 int oldTailX snake.x[snake.tail]; int oldTailY snake.y[snake.tail]; // 移動蛇身 moveSnake(snake, nx, ny, eat); // 繪制新蛇頭 gotoxy(nx, ny); SetConsoleTextAttribute(handle, 10); std::cout ■; if (eat) { score 10; updateScoreBoard(); // 食物被吃了重新生成 spawnFood(); drawFood(); // 難度遞進(jìn) if (score % 50 0 interval 60) { interval - 10; } } else { // 沒吃到食物擦除尾巴 gotoxy(oldTailX, oldTailY); std::cout ; } }這段代碼基本就是第二版的核心了順序上有個關(guān)鍵點必須先判斷碰撞再執(zhí)行移動。如果把移動放在檢測之前蛇頭已經(jīng)撞墻了你還要再倒回去處理邏輯會變得很別扭。5.3 食物生成的安全策略最簡單的食物生成方式是循環(huán)隨機(jī)do { foodX rand() % MAP_WIDTH; foodY rand() % MAP_HEIGHT; } while (isBody(foodX, foodY));這在小地圖、蛇身短的時候沒問題但當(dāng)蛇身占滿大半地圖時隨機(jī)位置很可能反復(fù)落在蛇身上碰撞檢測次數(shù)急劇上升極端情況下程序會卡到看起來像死循環(huán)。更穩(wěn)妥的寫法是先把所有空地收集起來再從空地里隨機(jī)挑一個。這里給個實現(xiàn)void spawnFood() { int freeCells 0; for (int y 0; y MAP_HEIGHT; y) { for (int x 0; x MAP_WIDTH; x) { if (!isBody(x, y)) freeCells; } } if (freeCells 0) { // 地圖被占滿游戲勝利 state STATE_OVER; return; } int target rand() % freeCells; for (int y 0; y MAP_HEIGHT; y) { for (int x 0; x MAP_WIDTH; x) { if (!isBody(x, y)) { if (target 0) { foodX x; foodY y; return; } target--; } } } }這個函數(shù)的時間復(fù)雜度是O(地圖大小)即使蛇身很長也穩(wěn)定。游戲快通關(guān)時這個函數(shù)能保證食物一定生成在可到達(dá)的位置。5.4 完整的繪制函數(shù)組織把繪制代碼統(tǒng)一封裝能有效避免零散輸出。我通常按層次組織四個繪制函數(shù)drawBorder()只畫一次放在初始化階段之后不清除drawSnake()更新蛇時調(diào)用畫整個蛇身適合初始化以及重置后重繪drawFood()畫當(dāng)前位置的食物drawUI()在右側(cè)畫分?jǐn)?shù)、難度、操作提示。初始化時調(diào)用一次全部繪制之后每幀只做局部更新這樣代碼清晰也不會因為忘畫某個元素導(dǎo)致畫面殘缺。6. 常見問題與調(diào)試心得6.1 蛇頭畫出來了但身體斷節(jié)或者重疊這個問題十有八九出在環(huán)形數(shù)組的取模和head/tail更新順序上。記住一個口訣先寫新頭到(head1)%MAX_BODY再移動head游標(biāo)最后才根據(jù)是否吃到食物去動tail。如果先動了tail或者h(yuǎn)ead下一次繪制時遍歷的起點就會錯亂。排查時可以打印head、tail和len三個值蛇動幾幀后再對比數(shù)組里每個坐標(biāo)很快就能定位是哪個環(huán)節(jié)出了問題。6.2 方向鍵沒反應(yīng)或者按一次走兩步方向鍵沒反應(yīng)優(yōu)先檢查有沒有處理224/0這個前導(dǎo)字節(jié)。只讀一次_getch()拿到的根本不是一個有效方向鍵。按一次走兩步多半是按鍵的“重復(fù)觸發(fā)”。如果你在循環(huán)里每次都調(diào)用readDirection()鍵盤按住時系統(tǒng)會連續(xù)產(chǎn)生重復(fù)按鍵事件。解決方法是維護(hù)一個“本幀是否已處理過方向”的標(biāo)志每幀只允許修改一次方向或者干脆用“方向緩沖”只在方向鍵第一次生效。6.3 速度忽快忽慢前面提過別用Sleep做速度控制要用時間戳判斷。如果你已經(jīng)用了時間戳還是忽快忽慢注意檢查render()里有沒有執(zhí)行耗時大的操作比如每次渲染都遍歷整個地圖或者調(diào)用SetConsoleTextAttribute太多。優(yōu)化思路是把顏色設(shè)置也緩存下來只在變化時才調(diào)用。6.4 蛇吃到自己的身體竟然沒死最可能的原因是自碰檢測時忘了考慮“尾巴即將移走”的情況。當(dāng)蛇沒有吃到食物時新的蛇頭可以合法地移動到當(dāng)前尾巴的位置因為那一幀尾巴會立刻移開。如果檢測函數(shù)把尾巴計入身體游戲會在本該活著的時候判死。反過來如果吃到食物時尾巴不移走你確實應(yīng)該判死。所以檢測函數(shù)的第三個參數(shù)grow必須精確傳達(dá)“這一幀蛇是否增長”的狀態(tài)兩個分支處理方式不同。6.5 我給新手的排查技巧單步調(diào)試模式調(diào)試游戲邏輯比調(diào)試普通程序難點在于畫面一直在變。我的做法是加一個隱藏的“單步模式”按P鍵進(jìn)入暫停按N鍵手動執(zhí)行一次update然后打印出蛇頭坐標(biāo)、方向、長度和下一幀新頭位置。if (key n state STATE_PAUSE) { updateGame(); drawGame(); }這個功能在排查“為什么這里撞了”“為什么那里沒撞”時極其有效你可以一幀一幀地觀察蛇的狀態(tài)變化。等所有邏輯都調(diào)通了再把這個調(diào)試分支注釋掉。在我看來控制臺貪吃蛇這個項目寫到這里已經(jīng)不是“能不能跑”的問題而是“好不好玩”的問題。環(huán)形數(shù)組把蛇身管理變得穩(wěn)定定點重繪消滅了閃爍固定時間戳讓加速減速都變得可控狀態(tài)機(jī)又給了你擴(kuò)展菜單和暫停的底氣。這一套思維不止適用于貪吃蛇你隨便拿一個控制臺小游戲套上“狀態(tài)機(jī)定時更新局部重繪”這個框架手感都會比瞎寫一氣的版本強(qiáng)很多。后面如果再想加?xùn)|西最高分存檔、蛇的AI自動尋路、雙人模式都只是在這個骨架上面繼續(xù)長肌肉了。