目——第二部分繪制菜單和初始界面:用TaoToken統(tǒng)一Key調(diào)試控制臺(tái)渲染)
1. 從黑框到菜單貪吃蛇初始界面到底難在哪很多人寫 C 語言貪吃蛇卡住的地方不是蛇怎么動(dòng)而是第一步——屏幕上那個(gè)菜單根本畫不出來。你打開 VS Code編譯通過運(yùn)行之后控制臺(tái)一片空白或者所有文字?jǐn)D在左上角光標(biāo)亂跳按方向鍵沒反應(yīng)。這不是你邏輯寫錯(cuò)了而是 Windows 控制臺(tái)的坐標(biāo)系統(tǒng)和鍵盤輸入模型跟你想的不一樣。C 語言標(biāo)準(zhǔn)庫里的printf只能順序輸出它沒有“把光標(biāo)移到第 10 行第 20 列”這種能力。而貪吃蛇的菜單、邊界、蛇身、食物全都需要精確的坐標(biāo)定位。所以你得借助 Windows 提供的控制臺(tái) API自己封裝一個(gè)gotoxy()才能讓字符出現(xiàn)在你想要的任何位置。這一步打通了后面畫墻、畫菜單、畫蛇都是同一套邏輯。這篇內(nèi)容聚焦的就是這個(gè)環(huán)節(jié)在 Windows VS Code 本地開發(fā)環(huán)境下用 C 語言把貪吃蛇的菜單和初始界面畫出來并且讓鍵盤上下鍵能選中菜單項(xiàng)、回車能確認(rèn)。適合已經(jīng)會(huì)寫for循環(huán)和printf、但沒接觸過控制臺(tái) API 的初學(xué)者。我會(huì)把每個(gè)函數(shù)的參數(shù)、坐標(biāo)計(jì)算、按鍵碼都拆開講代碼可以直接復(fù)制進(jìn)你的項(xiàng)目里跑。另外調(diào)試這類控制臺(tái)程序時(shí)編譯報(bào)錯(cuò)和運(yùn)行異常往往跟環(huán)境配置、頭文件缺失、字符編碼有關(guān)。我會(huì)在關(guān)鍵步驟里穿插用 TaoToken 統(tǒng)一 Key 通道輔助排查編譯與運(yùn)行報(bào)錯(cuò)的動(dòng)作讓你在遇到undefined reference或亂碼時(shí)有個(gè)可驗(yàn)證的排查路徑。整篇的目標(biāo)很明確你跟著做完能獨(dú)立跑通菜單顯示和鍵盤選擇交互。2. 前置準(zhǔn)備Windows 控制臺(tái) API 與 TaoToken 統(tǒng)一 Key 通道2.1 你需要包含的頭文件在 Windows 下做控制臺(tái)坐標(biāo)定位核心是兩個(gè)頭文件#include windows.h #include conio.hwindows.h提供控制臺(tái)句柄和光標(biāo)定位相關(guān)的 API。conio.h提供kbhit()和getch()用來做非阻塞鍵盤檢測(cè)。這兩個(gè)頭文件在 Windows 的 MinGW 或 MSVC 編譯器里都有VS Code 配合 MinGW-w64 就能直接用。windows.h里我們重點(diǎn)用三個(gè)東西。第一是HANDLE它是標(biāo)準(zhǔn)設(shè)備的句柄通過GetStdHandle(STD_OUTPUT_HANDLE)拿到標(biāo)準(zhǔn)輸出的句柄。第二是COORD結(jié)構(gòu)體它有兩個(gè)成員X和Y都是SHORT類型用來表示光標(biāo)在控制臺(tái)緩沖區(qū)里的列和行。第三是SetConsoleCursorPosition()它接收句柄和COORD把光標(biāo)移動(dòng)到指定位置。conio.h里重點(diǎn)用兩個(gè)函數(shù)。kbhit()判斷當(dāng)前是否有鍵盤輸入有輸入返回非零值沒有返回 0它不會(huì)阻塞程序。getch()獲取按下的鍵值不需要按回車適合做實(shí)時(shí)交互。注意getch()在舊編譯器里有個(gè)gotoxy()函數(shù)但現(xiàn)代編譯器已經(jīng)不支持了所以我們必須自己實(shí)現(xiàn)。2.2 自己實(shí)現(xiàn) gotoxy()把光標(biāo)移動(dòng)到(x, y)的函數(shù)實(shí)現(xiàn)如下void gotoxy(int x, int y) { HANDLE handle GetStdHandle(STD_OUTPUT_HANDLE); COORD pos { .X x, .Y y }; SetConsoleCursorPosition(handle, pos); }這里pos用了指定初始化器.X和.Y分別賦值。SetConsoleCursorPosition執(zhí)行后下一次putchar或puts就會(huì)從這個(gè)坐標(biāo)開始輸出。注意坐標(biāo)原點(diǎn)(0,0)在控制臺(tái)左上角X 向右增大Y 向下增大。2.3 為什么這里要提 TaoToken 統(tǒng)一 Key 通道控制臺(tái)項(xiàng)目本身不需要聯(lián)網(wǎng)但你在調(diào)試階段會(huì)遇到兩類問題一是編譯報(bào)錯(cuò)比如頭文件找不到、鏈接錯(cuò)誤二是運(yùn)行時(shí)報(bào)錯(cuò)比如字符編碼導(dǎo)致中文亂碼、控制臺(tái)緩沖區(qū)大小不對(duì)導(dǎo)致坐標(biāo)越界。這些問題有時(shí)候靠搜索引擎翻半天不如直接用一個(gè)能對(duì)話的模型通道快速定位。TaoToken 提供統(tǒng)一 Key 和 API 通道你可以把它理解成一個(gè)統(tǒng)一的模型調(diào)用入口用同一個(gè) Key 就能訪問多種模型能力。在排查編譯錯(cuò)誤時(shí)把報(bào)錯(cuò)信息貼進(jìn)去讓它幫你分析是頭文件路徑問題還是鏈接參數(shù)問題比逐條查文檔快。它的 API 地址是https://taotoken.net/api控制臺(tái)和 API Keys 管理在官網(wǎng)對(duì)應(yīng)頁面。你不需要在代碼里集成它它只是你調(diào)試階段的輔助工具。我試過在 VS Code 里編譯報(bào)undefined reference to SetConsoleCursorPosition的時(shí)候把完整報(bào)錯(cuò)貼給模型通道它直接指出是鏈接時(shí)沒帶上-lkernel32加上就好了。這種問題搜索引擎也能查到但模型通道能結(jié)合你的代碼上下文給更具體的建議。2.4 項(xiàng)目常量定義在寫繪制函數(shù)之前先把寬高和菜單項(xiàng)位置定義好#define WIDTH 30 #define HEIGHT 20 const char start_game[] START GAME; const char exit_game[] EXIT GAME; COORD start_pos { .X WIDTH/2 - strlen(start_game)/2, .Y HEIGHT/2 }; COORD exit_pos { .X WIDTH/2 - strlen(exit_game)/2, .Y HEIGHT/2 2 };WIDTH和HEIGHT是游戲區(qū)域的寬高邊界會(huì)畫到WIDTH和HEIGHT這個(gè)坐標(biāo)。菜單項(xiàng)的水平位置用WIDTH/2減去字符串長度的一半這樣文字就居中。兩個(gè)菜單項(xiàng)之間隔 2 行視覺上不擠。3. 可復(fù)制配置繪制邊界、菜單與鍵盤選擇3.1 繪制 30×20 的邊界墻邊界墻的作用是限定蛇的活動(dòng)范圍同時(shí)給玩家一個(gè)視覺框架。實(shí)現(xiàn)方式是雙重循環(huán)遍歷每一行每一列在邊界位置輸出*內(nèi)部輸出空格void draw_wall(void) { gotoxy(0, 0); for (int y 0; y HEIGHT; y) { for (int x 0; x WIDTH; x) { if (x 0 || x WIDTH) putchar(*); else if (y 0 || y HEIGHT) putchar(*); else putchar( ); } puts(); } }外層循環(huán)控制行y內(nèi)層循環(huán)控制列x。當(dāng)x等于 0 或WIDTH時(shí)說明在左右邊界輸出*當(dāng)y等于 0 或HEIGHT時(shí)說明在上下邊界也輸出*其余位置輸出空格。每畫完一行用puts()換行。最終得到一個(gè) 30 列 20 行的矩形邊框。這里有個(gè)細(xì)節(jié)gotoxy(0,0)先把光標(biāo)歸位避免上一次運(yùn)行殘留的光標(biāo)位置影響繪制。如果你發(fā)現(xiàn)邊界畫歪了先檢查這個(gè)歸位有沒有加。3.2 繪制菜單文字菜單文字用puts輸出到預(yù)先算好的坐標(biāo)void draw_menu(void) { gotoxy(start_pos.X, start_pos.Y); puts(start_game); gotoxy(exit_pos.X, exit_pos.Y); puts(exit_game); }gotoxy把光標(biāo)移到start_posputs輸出START GAME然后移到exit_pos輸出EXIT GAME。因?yàn)樽鴺?biāo)已經(jīng)算好了居中位置所以文字會(huì)出現(xiàn)在游戲區(qū)域中間偏上的位置。3.3 鍵盤輸入函數(shù) get_key()這個(gè)函數(shù)負(fù)責(zé)把按鍵轉(zhuǎn)換成操作指令。方向鍵在控制臺(tái)里會(huì)先返回 224再返回具體鍵值所以要判斷兩次#define Up 1 #define Down 2 #define Left 3 #define Right 4 #define Center 5 int get_key(void) { int kb kbhit(); if (kb 0) return 0; int key getch(); if (key 224) key getch(); if (key w || key W || key 72) return Up; else if (key s || key S || key 80) return Down; else if (key a || key A || key 75) return Left; else if (key d || key D || key 77) return Right; else if (key 13) return Center; return 0; }kbhit()返回 0 表示沒有按鍵直接返回 0。有按鍵時(shí)用getch()取值。如果取到 224說明是特殊鍵方向鍵、功能鍵需要再調(diào)一次getch()拿到真正的鍵值。方向鍵的鍵值上 72、下 80、左 75、右 77。字母鍵w/a/s/d和對(duì)應(yīng)的大寫也映射到同樣的操作?;剀囨I值是 13映射為Center。3.4 菜單選擇函數(shù) select_menu()這個(gè)函數(shù)用上下鍵切換選中項(xiàng)用回車確認(rèn)int select_menu(void) { COORD menu_int_pos start_pos; gotoxy(start_pos.X - 2, start_pos.Y); putchar(O); while (1) { int key get_key(); if (key Up || key Down) { if (menu_int_pos.X start_pos.X menu_int_pos.Y start_pos.Y) { gotoxy(start_pos.X - 2, start_pos.Y); putchar( ); gotoxy(exit_pos.X - 2, exit_pos.Y); putchar(O); menu_int_pos exit_pos; } else if (menu_int_pos.X exit_pos.X menu_int_pos.Y exit_pos.Y) { gotoxy(exit_pos.X - 2, exit_pos.Y); putchar( ); gotoxy(start_pos.X - 2, start_pos.Y); putchar(O); menu_int_pos start_pos; } } if (key Center) { gotoxy(0, HEIGHT 2); if (menu_int_pos.X start_pos.X menu_int_pos.Y start_pos.Y) return 1; else if (menu_int_pos.X exit_pos.X menu_int_pos.Y exit_pos.Y) return 0; } } }初始時(shí)在start_pos左邊 2 列畫一個(gè)O表示選中。進(jìn)入循環(huán)后不斷讀鍵。如果按了上或下就把當(dāng)前選中項(xiàng)的O擦掉輸出空格在另一項(xiàng)旁邊畫O同時(shí)更新menu_int_pos。如果按了回車根據(jù)當(dāng)前選中項(xiàng)返回 1 或 0。返回 1 表示開始游戲返回 0 表示退出。3.5 主函數(shù)串起來int main(void) { draw_wall(); draw_menu(); int menu select_menu(); if (menu 0) return 0; return 0; }先畫墻再畫菜單文字然后進(jìn)入選擇循環(huán)。選退出就返回 0 結(jié)束選開始就繼續(xù)往下走后續(xù)接蛇的初始化。3.6 編譯配置片段如果你用 VS Code MinGWtasks.json里的編譯參數(shù)大概是這樣{ version: 2.0.0, tasks: [ { label: build snake, type: shell, command: gcc, args: [ -g, main.c, -o, snake.exe, -lkernel32 ], group: { kind: build, isDefault: true } } ] }關(guān)鍵是-lkernel32因?yàn)镾etConsoleCursorPosition和GetStdHandle都在 kernel32 庫里。少了這個(gè)參數(shù)就會(huì)報(bào)鏈接錯(cuò)誤。4. 驗(yàn)證請(qǐng)求與成功結(jié)果跑通菜單交互4.1 編譯并運(yùn)行在 VS Code 終端里執(zhí)行g(shù)cc -g main.c -o snake.exe -lkernel32 ./snake.exe如果編譯沒有報(bào)錯(cuò)運(yùn)行后你應(yīng)該看到一個(gè) 30×20 的星號(hào)邊框中間偏上位置有START GAME和EXIT GAME兩行文字START GAME左邊有一個(gè)O表示當(dāng)前選中。4.2 驗(yàn)證鍵盤交互按鍵盤的下方向鍵O應(yīng)該從START GAME跳到EXIT GAME。再按上方向鍵O跳回來。按回車鍵如果當(dāng)前選中START GAME程序會(huì)繼續(xù)往下執(zhí)行如果選中EXIT GAME程序直接退出。你也可以用w/s代替上下鍵用回車確認(rèn)。這套交互驗(yàn)證通過說明坐標(biāo)定位、鍵盤檢測(cè)、菜單狀態(tài)切換三個(gè)環(huán)節(jié)都正常。4.3 用 TaoToken 通道輔助驗(yàn)證報(bào)錯(cuò)如果你運(yùn)行時(shí)遇到問題比如菜單文字位置不對(duì)、按鍵沒反應(yīng)、程序閃退可以把現(xiàn)象和代碼片段整理一下通過 TaoToken 的模型對(duì)話通道提問。比如你貼出get_key函數(shù)和“按方向鍵沒反應(yīng)”的描述模型會(huì)幫你檢查是不是kbhit判斷邏輯寫反了或者getch調(diào)用次數(shù)不對(duì)。TaoToken 的 API 地址是https://taotoken.net/api你可以在控制臺(tái)里創(chuàng)建 API Key然后用模型對(duì)話功能做調(diào)試問答。它不替代你的編譯器但能幫你快速定位邏輯錯(cuò)誤。對(duì)于長期做 C 語言項(xiàng)目調(diào)試的場景也可以了解它的 Coding Plan把模型通道作為日常開發(fā)輔助。4.4 成功結(jié)果的判斷標(biāo)準(zhǔn)跑通的標(biāo)準(zhǔn)有三條第一邊框完整閉合沒有缺角或錯(cuò)位第二兩個(gè)菜單項(xiàng)文字水平居中垂直間距合理第三上下鍵能切換選中標(biāo)記回車能正確返回。三條都滿足這一部分的初始界面就算完成了。5. 本篇常見錯(cuò)誤排查5.1 報(bào)錯(cuò)undefined reference to SetConsoleCursorPosition這是最常見的鏈接錯(cuò)誤。原因是你用了windows.h里的 API但編譯時(shí)沒有鏈接 kernel32 庫。解決方法是編譯命令加上-lkernel32gcc -g main.c -o snake.exe -lkernel32如果你在 VS Code 的tasks.json里配置就在args數(shù)組里加上-lkernel32。這個(gè)錯(cuò)誤不會(huì)在編譯階段報(bào)只在鏈接階段出現(xiàn)所以看到undefined reference先檢查鏈接參數(shù)。5.2 報(bào)錯(cuò)COORD undeclared或HANDLE undeclared這說明windows.h沒包含進(jìn)來或者包含順序有問題。檢查文件開頭是否有#include windows.h。另外注意windows.h最好放在其他頭文件之前避免宏定義沖突。如果你同時(shí)用了conio.h順序?qū)懗?include windows.h #include conio.h #include stdio.h #include string.h5.3 運(yùn)行后菜單文字?jǐn)D在左上角這種情況通常是gotoxy沒生效或者start_pos計(jì)算錯(cuò)誤。先檢查gotoxy里SetConsoleCursorPosition的返回值如果返回 0 說明調(diào)用失敗。再檢查start_pos.X的計(jì)算WIDTH/2 - strlen(start_game)/2如果WIDTH是 30strlen(START GAME)是 10那么X 15 - 5 10這是對(duì)的。如果你把WIDTH定義成了別的值重新算一下。還有一種可能是控制臺(tái)緩沖區(qū)寬度不夠?qū)е伦鴺?biāo)被截?cái)?。可以在main開頭加一句調(diào)整緩沖區(qū)system(mode con cols60 lines30);5.4 按方向鍵沒反應(yīng)或者程序直接退出先確認(rèn)get_key里kbhit()的判斷。kbhit()返回非零表示有按鍵返回 0 表示沒有。如果你寫成if (kb 1) return 0;就反了。正確寫法是if (kb 0) return 0;。另一個(gè)常見問題是方向鍵的鍵值判斷。方向鍵第一次getch()返回 224第二次才返回 72/80/75/77。如果你只調(diào)了一次getch()拿到的就是 224不會(huì)匹配任何方向。檢查有沒有if (key 224) key getch();這一行。5.5 菜單選中標(biāo)記O位置偏移O畫在start_pos.X - 2的位置也就是文字左邊兩列。如果你發(fā)現(xiàn)O和文字重疊或者離太遠(yuǎn)檢查start_pos.X是否算對(duì)。另外擦除舊標(biāo)記時(shí)用的是putchar( )要確保擦除的坐標(biāo)和畫標(biāo)記的坐標(biāo)一致否則會(huì)留下殘影。5.6 編譯通過但運(yùn)行閃退閃退通常是select_menu里的while(1)循環(huán)出了問題或者get_key返回了未定義的值導(dǎo)致邏輯走飛。可以在select_menu的while循環(huán)里加一句調(diào)試輸出看看每次按鍵返回什么值。另外確認(rèn)main函數(shù)最后有return 0;沒有的話某些編譯器會(huì)報(bào)異常。如果你用 TaoToken 的模型對(duì)話通道排查把get_key和select_menu兩個(gè)函數(shù)貼進(jìn)去描述“按回車后閃退”模型通常會(huì)指出是menu_int_pos比較邏輯有遺漏或者Center分支沒有正確處理返回值。5.7 字符編碼導(dǎo)致中文亂碼本篇代碼里菜單文字用的是英文不會(huì)亂碼。但如果你后續(xù)想改成中文菜單需要在文件保存時(shí)選擇 UTF-8 編碼并且在編譯時(shí)加-fexec-charsetGBK否則控制臺(tái)顯示會(huì)亂。這個(gè)問題在 Windows 下很常見提前知道可以少走彎路。6. 繼續(xù)往下走把初始界面接入完整游戲循環(huán)菜單和初始界面跑通之后下一步就是把蛇的初始化和食物生成接進(jìn)來。蛇的初始位置可以放在WIDTH/2和HEIGHT/2用O表示蛇頭*表示蛇身。食物用rand()在游戲區(qū)域內(nèi)隨機(jī)生成生成時(shí)要檢查不能和蛇身重疊。這部分的核心思路跟菜單繪制是一樣的用gotoxy定位用putchar輸出字符。你已經(jīng)有了一套可用的坐標(biāo)定位和鍵盤檢測(cè)工具后面畫蛇、移動(dòng)蛇、吃食物、判斷碰撞都是在這套工具上疊加邏輯。如果你在接入蛇的移動(dòng)時(shí)遇到蛇身刷新殘影、方向鍵響應(yīng)延遲、食物生成在邊界外這些問題可以繼續(xù)用 TaoToken 的模型通道做針對(duì)性排查。把draw_int_snake和generate_food_pos的代碼貼進(jìn)去描述具體現(xiàn)象比盲猜快很多。API 通道地址是https://taotoken.net/api控制臺(tái)里可以管理 Key 和查看接入文檔。最后提醒一個(gè)實(shí)操細(xì)節(jié)每次修改代碼后重新編譯記得先關(guān)掉上一次運(yùn)行的控制臺(tái)窗口否則新程序可能因?yàn)榕f窗口占用而無法正常輸出。這個(gè)坑我在剛開始做控制臺(tái)項(xiàng)目時(shí)踩過好幾次窗口沒關(guān)就重新運(yùn)行結(jié)果看到的是舊界面還以為代碼沒生效。