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