實(shí)戰(zhàn):BFS尋路與安全決策讓蛇與身體共舞)
簡(jiǎn)介一套以“貪吃蛇之與蛇共舞”為主題的Python游戲編程課程設(shè)計(jì)大作業(yè)源碼面向有一定Python基礎(chǔ)、正在完成課程設(shè)計(jì)或想嘗試AI體感游戲開發(fā)的學(xué)生。該項(xiàng)目在pgzero1.2的snake框架上擴(kuò)展利用百度AI人體關(guān)鍵點(diǎn)分析接口經(jīng)攝像頭識(shí)別人體姿態(tài)并根據(jù)手臂、頭部和肩膀的相對(duì)位置生成上下左右方向指令再通過(guò)socket127.0.0.1:20163傳給標(biāo)準(zhǔn)貪吃蛇程序?qū)崿F(xiàn)了“我做運(yùn)動(dòng)、小蛇吃蘋果”的趣味交互。打包內(nèi)容共43個(gè)文件約9.47MB包括py主程序與功能模塊、ipynb交互式調(diào)試筆記、pptx作品展示稿、png/jpg圖片素材、mp3背景音樂(lè)以及sh啟動(dòng)腳本等同時(shí)附有開發(fā)文檔、代碼注釋和中文使用說(shuō)明目錄結(jié)構(gòu)清晰便于按模塊拆解。這套資料完整覆蓋攝像頭姿態(tài)識(shí)別、標(biāo)準(zhǔn)貪吃蛇和音樂(lè)播放三大模塊可作為課程設(shè)計(jì)大作業(yè)源碼參考也可作為百度AI開放平臺(tái)入門實(shí)踐的樣例。目前已有936人學(xué)習(xí)瀏覽。1. 貪吃蛇課設(shè)怎么做才不像“作業(yè)”先想清楚AI要解決什么一個(gè) python 游戲編程課程設(shè)計(jì)大作業(yè)想拿高分光把貪吃蛇“做出來(lái)”是不夠的。真正拉開差距的地方是標(biāo)題后半句“AI 智能小游戲”。我見過(guò)很多交上來(lái)的源碼包蛇能走、能吃、能撞死但所謂 AI 只是隨機(jī)選方向老師一問(wèn)“你講講這個(gè) AI 的思路”就卡住了。這個(gè)題目真正的技術(shù)點(diǎn)是讓蛇像活物一樣和自己的身體“共舞”知道往哪走能吃到食物也知道什么時(shí)候該放棄食物去保命甚至能做到永不自撞、一路吃到棋盤占滿。這篇就按我實(shí)際做這類課設(shè)的思路從 pygame 骨架、AI 尋路策略到答辯演示完整講一遍。適合剛學(xué)完 python 基礎(chǔ)、想交一份有講頭作業(yè)的學(xué)生。2. 游戲骨架怎么搭pygame 循環(huán)、對(duì)象劃分與源碼包結(jié)構(gòu)2.1 源碼包為什么要拆成六個(gè)文件很多 python 新手交的貪吃蛇是一個(gè)人、一個(gè)文件、三百行代碼從頭寫到尾。能跑但擴(kuò)展性為零一旦要在里面加 AI改一次崩一次。我一般會(huì)把源碼包拆成下面這幾個(gè)模塊每個(gè)模塊只干一件事文件職責(zé)關(guān)鍵內(nèi)容settings.py全局參數(shù)格子數(shù)、像素尺寸、FPS、蛇初試長(zhǎng)度snake.py蛇本體身體坐標(biāo)列表、移動(dòng)、增長(zhǎng)、自撞檢測(cè)food.py食物管理隨機(jī)生成、避免生成在蛇身上ai.pyAI 決策BFS 尋路、安全判定、方向選擇game.py游戲流程狀態(tài)切換、得分、死亡判定、繪制調(diào)度main.py入口pygame 初始化、事件循環(huán)、主循環(huán)這么拆有幾個(gè)實(shí)際好處。第一調(diào)參數(shù)不用滿文件找人打開 settings.py 就能改速度、改棋盤大小。第二AI 邏輯和游戲邏輯隔離把 ai.py 整個(gè)換掉就能對(duì)比“貪心 AI”和“路徑規(guī)劃 AI”的差別答辯時(shí)這是一個(gè)非常好的展示點(diǎn)。第三出 bug 時(shí)定位快蛇行為異常查 snake.pyAI 犯傻查 ai.py不用在一坨代碼里面做閱讀理解。2.2 主循環(huán)三段式事件、更新、渲染pygame 程序的核心是主循環(huán)。這個(gè)循環(huán)每次迭代做三件事處理事件、更新游戲狀態(tài)、繪制畫面。我的 main.py 大概長(zhǎng)這樣import pygame import sys from settings import CELL_SIZE, GRID_WIDTH, GRID_HEIGHT, FPS from game import Game def main(): pygame.init() screen pygame.display.set_mode( (GRID_WIDTH * CELL_SIZE, GRID_HEIGHT * CELL_SIZE) ) pygame.display.set_caption(貪吃蛇之與蛇共舞 AI) clock pygame.time.Clock() game Game() while True: for event in pygame.event.get(): if event.type pygame.QUIT: pygame.quit() sys.exit() if event.type pygame.KEYDOWN: game.on_key(event.key) game.update() # 蛇移動(dòng)一步、AI 決策一次都在這里 screen.fill((20, 20, 30)) game.draw(screen) pygame.display.flip() clock.tick(FPS) if __name__ __main__: main()這里有個(gè)關(guān)鍵點(diǎn)game.update()每幀只跑一次那蛇的移動(dòng)速度就完全由clock.tick(FPS)決定。FPS 設(shè) 10就是每秒走 10 格這個(gè)手感比較接近經(jīng)典貪吃蛇。如果 FPS 設(shè)得過(guò)高又沒(méi)有給蛇做“計(jì)時(shí)步進(jìn)”蛇會(huì)快成一道光鍵盤根本反應(yīng)不過(guò)來(lái)。初中級(jí)課設(shè)這樣做是合理的高級(jí)一點(diǎn)可以把“邏輯更新頻率”和“渲染幀率”分開我會(huì)在避坑章節(jié)專門講這個(gè)坑。2.3 蛇對(duì)象的移動(dòng)與增長(zhǎng)邏輯蛇的本質(zhì)是一個(gè)坐標(biāo)列表頭在body[0]尾在body[-1]。每走一步往頭部插入一個(gè)新坐標(biāo)沒(méi)吃到食物就把尾部彈出去。方向不能用“上下左右”這種語(yǔ)義直接用坐標(biāo)增量表示# snake.py DIRS { UP: (0, -1), DOWN: (0, 1), LEFT: (-1, 0), RIGHT: (1, 0), } class Snake: def __init__(self, start_len3): self.body [(GRID_WIDTH // 2 i, GRID_HEIGHT // 2) for i in range(start_len)] self.direction LEFT self.growing False def move(self): dx, dy DIRS[self.direction] head (self.body[0][0] dx, self.body[0][1] dy) self.body.insert(0, head) if self.growing: self.growing False else: self.body.pop() # 沒(méi)吃到食物尾巴正常收回來(lái) def grow(self): self.growing True這段邏輯里容易被忽略的是body.pop()要發(fā)生在insert之后順序反過(guò)來(lái)就會(huì)出現(xiàn)蛇多長(zhǎng)一節(jié)或者少長(zhǎng)一節(jié)的怪異現(xiàn)象。另外注意growing標(biāo)志位只保留一幀吃到食物后move()會(huì)在下一幀被調(diào)用那時(shí)長(zhǎng)度才真正加一。這是 pygame 事件驅(qū)動(dòng)模型的常見節(jié)奏不是“立刻長(zhǎng)”而是“下一幀長(zhǎng)”。3. “與蛇共舞”的 AI 核心BFS 尋路、尾部跟隨與安全兜底3.1 為什么這個(gè)項(xiàng)目用 BFS 而不是 A*做 AI 尋路時(shí)很多人第一反應(yīng)是上 A*。但在貪吃蛇這個(gè)場(chǎng)景里我堅(jiān)持用 BFS廣度優(yōu)先搜索原因很實(shí)際棋盤只有 30×20 個(gè)格子最多 600 個(gè)狀態(tài)BFS 最壞情況也就是把這 600 個(gè)格子全展開一遍耗時(shí)可以忽略。A* 的優(yōu)勢(shì)靠啟發(fā)式剪枝在大地圖上才能體現(xiàn)在 600 格的小棋盤上省不了幾次計(jì)算卻多了一堆排序和估值函數(shù)的代碼要寫、要講、要答辯。BFS 還有兩個(gè)對(duì)課設(shè)特別友好的特性第一它天然給出最短路徑路徑長(zhǎng)度就是步數(shù)演示時(shí)你能直接說(shuō)“蛇走了 6 步吃到食物”第二代碼量小用一個(gè)deque加一個(gè) parent 字典就能實(shí)現(xiàn)老師讓現(xiàn)場(chǎng)講思路三句話能講完。from collections import deque def bfs_path(body, target): head body[0] grid_blocked set(body[:-1]) # 尾巴下一步會(huì)離開先不封死細(xì)節(jié)見避坑章節(jié) queue deque([head]) parent {head: None} while queue: cur queue.popleft() if cur target: # 從 parent 反向重建路徑 path [] while cur is not None: path.append(cur) cur parent[cur] return path[::-1] for dx, dy in ((1,0), (-1,0), (0,1), (0,-1)): nxt (cur[0] dx, cur[1] dy) if (0 nxt[0] GRID_WIDTH and 0 nxt[1] GRID_HEIGHT and nxt not in grid_blocked and nxt not in parent): parent[nxt] cur queue.append(nxt) return Nonegrid_blocked用集合而不是列表是 600 格小地圖上的一個(gè)有效加速用列表的in判斷是 O(n) 的蛇長(zhǎng)了以后每輪 BFS 多出幾千次無(wú)效比較。parent 字典記錄“從哪來(lái)”到終點(diǎn)后從后往前回溯再[::-1]反轉(zhuǎn)成從頭到尾的路徑。這個(gè)函數(shù)是后續(xù)所有 AI 決策的地基。3.2 AI 決策三步走吃食物、保命、追尾巴BFS 只是工具AI 的決策策略才是“與蛇共舞”的核心。一般來(lái)說(shuō)每條蛇每輪決策我會(huì)按優(yōu)先級(jí)走這三步。第一步如果 BFS 能找到一條去食物的路徑而且這條路走完之后蛇還有足夠的自由空間活動(dòng)就走這條路。第二步如果吃食物會(huì)把自己困死就改成 BFS 去追自己的尾巴跟著尾巴走能讓蛇始終處在一個(gè)安全回路里。第三步如果追尾巴也追不上就選一個(gè)“走了之后剩余空間最大”的方向茍著等待轉(zhuǎn)機(jī)。第二步是“與蛇共舞”這個(gè)名字的靈魂。追尾巴的本質(zhì)是把蛇身體當(dāng)成一條循環(huán)軌道頭追著尾走整個(gè)身體畫圈永遠(yuǎn)不會(huì)撞到自己。我要在 AI 決策里判斷“走這一步后還有沒(méi)有活路”這一步需要模擬def available_space(body, head): 計(jì)算從 head 出發(fā)能到達(dá)的空格子數(shù)量 visited {head} q deque([head]) blocked set(body) while q: x, y q.popleft() for dx, dy in ((1,0), (-1,0), (0,1), (0,-1)): nx, ny xdx, ydy if (0 nx GRID_WIDTH and 0 ny GRID_HEIGHT and (nx, ny) not in blocked and (nx, ny) not in visited): visited.add((nx, ny)) q.append((nx, ny)) return len(visited) def decide(snake, food): body snake.body head body[0] min_space len(body) * 2 # 安全閾值至少留出兩倍身長(zhǎng)的活動(dòng)空間 path bfs_path(body, food) if path: next_head path[1] simulated [next_head] body[:-1] # 模擬移動(dòng)一步后的身體 if available_space(simulated, next_head) min_space: return next_head # 吃不到或吃了會(huì)死改追尾巴 path bfs_path(body, body[-1]) if path and len(path) 1: next_head path[1] simulated [next_head] body[:-1] if available_space(simulated, next_head) min_space: return next_head # 兜底挑一個(gè)讓活動(dòng)空間最大的方向 best None best_score -1 for dx, dy in ((1,0), (-1,0), (0,1), (0,-1)): nx, ny head[0]dx, head[1]dy if (0 nx GRID_WIDTH and 0 ny GRID_HEIGHT and (nx, ny) not in body[:-1]): score available_space([(nx, ny)] body[:-1], (nx, ny)) if score best_score: best_score score best (nx, ny) return bestmin_space len(body) * 2這個(gè)閾值是我經(jīng)驗(yàn)里比較穩(wěn)的取值。蛇身 10 節(jié)時(shí)至少要留給蛇 20 格活動(dòng)空間否則進(jìn)了窄口子就很難出來(lái)。這里每一步都做一次“模擬移動(dòng) 空間計(jì)算”貪吃蛇每幀計(jì)算量幾十毫秒內(nèi)就能完成完全不影響游戲流暢度。3.3 可視化調(diào)試把路徑和死區(qū)畫出來(lái)AI 的坑在于它做的決策你看不到原因。我調(diào)試這類 AI 的第一件事就是加一個(gè)調(diào)試開關(guān)把 BFS 路徑和可達(dá)空間直接畫在游戲窗口上。# game.py 的 draw() 里 if ai_debug: path ai.last_path # 保存最近一次 BFS 結(jié)果 if path: for i, cell in enumerate(path): x, y cell[0]*CELL_SIZE, cell[1]*CELL_SIZE color (0, 255, 0) if i % 2 0 else (0, 200, 0) pygame.draw.circle(screen, color, (xCELL_SIZE//2, yCELL_SIZE//2), 4)開著這個(gè)開關(guān)跑一局你能清楚看到蛇的“想法”綠點(diǎn)是它打算去的路線死區(qū)一眼就能看出哪條路是死路。答辯現(xiàn)場(chǎng)開這個(gè)調(diào)試模式效果比任何流程圖都好因?yàn)樗故镜氖腔钌臎Q策過(guò)程。這是個(gè)值得做進(jìn)課設(shè)里的加分項(xiàng)。4. 避坑AI 瞎繞圈、拐角自盡與食物死角的排查記錄4.1 現(xiàn)象BFS 給出的路徑蛇走到一半反而被自己堵死我剛開始把 BFS 直接接進(jìn)游戲時(shí)蛇經(jīng)常走到一半突然自殺而且死的位置離食物還有好幾格看起來(lái)就像 AI“變傻”了。原因在 BFS 里對(duì)蛇尾的處理。bfs_path里我最初把整個(gè)身體都放進(jìn) blocked 集合set(body)把尾巴也算成不可走。但實(shí)際上蛇每走一步尾巴會(huì)同時(shí)讓出一格如果你把尾巴封死BFS 會(huì)繞遠(yuǎn)路甚至找不到路反過(guò)來(lái)如果完全不封尾巴BFS 可能規(guī)劃一條追著尾巴尖的路線蛇頭還沒(méi)到尾巴已經(jīng)移走了路徑自然失效。解決方法是避坑 3.2 里那個(gè)寫法blocked 集合用body[:-1]把尾巴那一格視為“下一步會(huì)空出來(lái)”。但要注意這個(gè)開放只對(duì)尾巴生效一格如果蛇很長(zhǎng)尾巴讓出的位置離頭很遠(yuǎn)BFS 還是可能規(guī)劃出穿過(guò)蛇身的路徑。所以正確邏輯是“路徑規(guī)劃 模擬移動(dòng) 空間安全判定”三件套一起用缺一個(gè)都會(huì)翻車。4.2 現(xiàn)象食物刷在死胡同角落AI 寧死也要吃棋盤是隨機(jī)的食物偶爾會(huì)生成在墻角或者生成在蛇身體圍成的口袋里。此時(shí) BFS 能到食物但那條路一旦走進(jìn)去身后就被自己身體堵死吃完食物直接原地去世。原因是我最初只判斷“有沒(méi)有路徑”沒(méi)判斷“路徑走完以后還有沒(méi)有活路”。新手很容易犯這個(gè)錯(cuò)BFS 找到路徑就一頭沖過(guò)去完全不考慮生存空間。解決方法是 3.2 的 min_space 閾值判斷。發(fā)現(xiàn)“吃了食物但剩余空間不夠”時(shí)立刻放棄食物轉(zhuǎn)身追尾巴。還有一個(gè)經(jīng)驗(yàn)值是連續(xù)五次決策都不選食物時(shí)寧可原地繞圈也不要強(qiáng)行穿窄縫等食物刷新到開闊地帶再去吃。這個(gè)邏輯寫起來(lái)就三行但對(duì)于 AI 的生存率提升非常明顯。4.3 現(xiàn)象AI 模式下按鍵盤蛇突然反向自撞在做鍵盤和 AI 雙模式時(shí)我遇到過(guò)這么個(gè)問(wèn)題AI 接管時(shí)我按了一下方向鍵想手動(dòng)操作蛇直接一頭撞到自己身上。原因事件循環(huán)收到鍵盤事件后立刻改了蛇的 direction但 AI 決策這一幀已經(jīng)結(jié)束了相當(dāng)于手動(dòng)方向越過(guò) AI 直接生效。如果新方向跟蛇當(dāng)前運(yùn)動(dòng)方向相反比如正在往右你按了左下一步蛇頭就直接踩到自己第二節(jié)身體上當(dāng)場(chǎng)死亡。這是貪吃蛇所有死亡方式里最容易出現(xiàn)、也最容易讓評(píng)委覺(jué)得“這個(gè) AI 不穩(wěn)”的一種。解決方法是給模式加鎖。AI 模式下鍵盤事件直接跳過(guò)只有切到手動(dòng)模式才處理按鍵。要演示“玩家介入”的話正確做法是顯式按 M 鍵切換模式而不是直接按方向鍵。if event.type pygame.KEYDOWN: if event.key pygame.K_m: game.ai_active not game.ai_active elif not game.ai_active: game.snake.set_direction_from_key(event.key)4.4 現(xiàn)象FPS 調(diào)高后蛇沒(méi)變快反而畫面漂移加發(fā)虛貪吃蛇傳統(tǒng)做法是用clock.tick(FPS)同時(shí)控制渲染幀率和蛇的移動(dòng)頻率。FPS 從 10 調(diào)到 30蛇應(yīng)該變快但實(shí)際跑起來(lái)蛇是偶爾抖兩格視覺(jué)上明顯不連貫。原因pygame 的 tick 只是“每幀間隔至少 1000/FPS 毫秒”不是嚴(yán)格讓游戲邏輯每固定時(shí)間走一步。系統(tǒng)卡頓一幀蛇就跳兩格系統(tǒng)流暢蛇又走得慢。對(duì)于需要 AI 配合的蛇這種跳幀會(huì)直接導(dǎo)致碰撞誤判。解決方法是把“游戲步進(jìn)”和“渲染”分開。維護(hù)一個(gè)計(jì)時(shí)器蛇每 100 毫秒固定走一步渲染則盡量保持流暢。step_time 0 STEP_INTERVAL_MS 100 while running: dt clock.tick(60) step_time dt while step_time STEP_INTERVAL_MS: game.update() # 蛇移動(dòng)固定節(jié)奏 step_time - STEP_INTERVAL_MS game.draw(screen) pygame.display.flip()這樣蛇 100 毫秒走一格雷打不動(dòng)。AI 有穩(wěn)定的思考節(jié)奏手工操作的手感也完全可控。4.5 現(xiàn)象AI 活到后期忽然開始原地畫圈前期一切正常蛇越長(zhǎng)越大到了填滿小半個(gè)棋盤時(shí)AI 忽然在一個(gè)沒(méi)食物的小空地上反復(fù)轉(zhuǎn)圈怎么都不出去找食物。原因安全閾值len(body) * 2在蛇變長(zhǎng)后變得非??量?。蛇 30 節(jié)時(shí)要 60 格自由空間但棋盤總共 600 格剩余空間可能只有 80 格蛇認(rèn)為哪條路都不安全于是默認(rèn)進(jìn)入“兜底模式”一直選空間最大的方向被困在局部。解決閾值不應(yīng)該是線性的。我最后改成min_space max(len(body), 20)即至少留出等于身長(zhǎng)的空格。蛇身較短時(shí)這個(gè)閾值足夠保守蛇身較長(zhǎng)時(shí)也不會(huì)因?yàn)橐筮^(guò)多而不敢行動(dòng)。另外后期食物刷新可以限制在蛇頭前方區(qū)域這也是很多商業(yè)貪吃蛇游戲的常見處理。5. 答辯還能多演一局錄像回放、難度曲線與自動(dòng)演示5.1 錄像回放把 AI 的完整表演存下來(lái)現(xiàn)在的課設(shè)答辯普遍有個(gè)尷尬現(xiàn)狀現(xiàn)場(chǎng)演示時(shí) AI 不配合死在奇怪的地方。我的習(xí)慣是給游戲加一個(gè)“錄像回放”功能把每一局的決策序列保存下來(lái)答辯時(shí)直接放錄好的完整局。# 錄像記錄幀號(hào)和方向 record [] def record_frame(game): record.append((game.tick_count, game.snake.direction)) # 回放每幀從記錄里取出方向覆蓋鍵盤輸入 def replay_frame(game, index): tick, direction record[index] if game.tick_count tick: game.snake.direction direction錄像只記錄決策不記錄畫面占用的內(nèi)存幾乎可以忽略。回放時(shí)把鍵盤事件禁用直接按記錄驅(qū)動(dòng)蛇。這樣你可以在答辯前跑出十條漂亮的長(zhǎng)對(duì)局從中挑一條最精彩的存下來(lái)。演示前注意確認(rèn)回放速度和原速一致用 5.1 的固定步進(jìn)寫法就不容易出現(xiàn)回放節(jié)奏錯(cuò)亂。5.2 難度曲線與自動(dòng)演示參數(shù)課設(shè)要想拿高分另一個(gè)容易被忽視的點(diǎn)是“游戲性”。AI 再聰明如果玩家一上來(lái)就被虐體驗(yàn)也不好。我會(huì)在 settings.py 里加一個(gè)難度曲線表關(guān)卡蛇長(zhǎng)區(qū)間FPS步/秒13-5826-1010311-1513416-201652120每吃一個(gè)食物蛇長(zhǎng)加一關(guān)卡自動(dòng)切換速度跟著漲。AI 在這個(gè)框架下也會(huì)表現(xiàn)得更有層次前期橫沖直撞中后期開始頻繁追尾游走也就是“與蛇共舞”的完整狀態(tài)。答辯現(xiàn)場(chǎng)演示時(shí)可以準(zhǔn)備一個(gè)簡(jiǎn)單的自動(dòng)演示模式啟動(dòng)后 AI 自動(dòng)接管打滿一盤統(tǒng)計(jì)得分、步數(shù)、存活時(shí)間打印在窗口頂部。這組數(shù)據(jù)就是 AI 水平和策略優(yōu)劣的直接證據(jù)。還有一個(gè)實(shí)操細(xì)節(jié)是命名每個(gè)版本的源碼包要留版本號(hào)比如snake_ai_v2_bfs_tail這種方便答辯時(shí)講“我嘗試過(guò)單純 BFS 版本、BFS追尾版本、加空間安全判定版本”版本對(duì)比本身就是最能撐起課設(shè)深度的材料?,F(xiàn)在我再做同類項(xiàng)目第一件事永遠(yuǎn)是先做回放和對(duì)比功能沒(méi)有對(duì)比數(shù)據(jù)AI 算法講得再好也容易被看成自說(shuō)自話。希望這個(gè)思路能幫你在答辯現(xiàn)場(chǎng)少一點(diǎn)緊張多一點(diǎn)底氣。本文還有配套的精品資源點(diǎn)擊獲取