1. 背景與目標
貪吃蛇是最適合入門的 2D 網頁小游戲之一:規則簡單、反饋清晰、可擴展空間大(穿墻模式、道具、多食物、排行榜……)。
demo地址:https://game.haiyong.site/snake-game.html
本項目的目標是:
- 純前端、零依賴:一個 HTML 文件搞定(你提供的版本已內聯 CSS/JS;也可輕松拆分為三文件)。
- 屏幕高清適配(DPR):在 1× 與 2× 屏幕上都不糊。
- 多端輸入:鍵盤 + 觸控滑動 + 移動端虛擬方向鍵。
- 基礎玩法完善:吃食物加分加速、不可 180° 反向、穿墻可切換。
- 體驗細節:音效開關、本地最高分存檔、狀態燈、結束面板。
- 架構清晰:有狀態機與時間驅動的主循環,易于擴展。
2. 需求拆解與技術選型
2.1 功能需求清單
- 畫布區域:固定邏輯分辨率 600×600,基于 20×20 網格。
- 信息面板:分數 / 最高分 / 運行狀態 / 難度選擇。
- 控制面板:開始/暫停、重置、穿墻模式、音效開關。
- 交互:鍵盤(方向 / 空格 / R)+ 觸控滑動 + 移動端虛擬方向鍵。
- 玩法:吃食物 +10 分;每 50 分提速(下限 80ms/格);撞墻或撞自己 Game Over。
- 存檔:最高分寫入
localStorage
。 - 細節:狀態指示燈、結束模態、DPR 適配、觸控閾值、按鈕響應式布局。
2.2 技術棧
- HTML5 Canvas:繪制網格、食物與蛇體。
- CSS:控制面板與信息欄;移動端響應式。
- JavaScript(原生):游戲循環、狀態機、事件綁定、碰撞檢測。
- Web Audio API(可降級):吃食物與失敗音效。
- localStorage:最高分記憶。
3. 界面與樣式:把玩法信息「可視化」
你提供的 HTML 結構已經非常貼近上線形態:
.game-info
頂欄用來展示分數、最高分、狀態文本與狀態燈(.status-indicator
)。.game-controls
包含四個按鈕(開始/暫停、重置、穿墻模式、音效)。.mobile-controls
中的.touch-pad
是 3×3 網格,布局出 ↑ ← ↓ → 的虛擬鍵,僅在 <768px 時顯示。.game-message
是結束/提示用的模態層,避免alert
破壞體驗。
這里有幾個小亮點:
-
狀態燈
僅用一個running
類控制顏色(紅/綠)。與statusText
文本聯動,能快速傳達狀態。 -
按鈕語義與可達性
按鈕文本會隨狀態切換,例如「穿墻模式: 開/關」,讓用戶始終知道當前配置。 -
響應式體驗
使用@media (max-width: 768px)
切換移動端 UI,桌面端則隱藏虛擬方向。
4. 畫布與 DPR 適配:清晰不糊的關鍵
在高清屏幕上,Canvas 如果只設置 CSS 尺寸會模糊。正確做法是邏輯尺寸 + 像素尺寸分離:
const dpr = window.devicePixelRatio || 1;
canvas.width = LOGICAL_WIDTH * dpr; // 實際像素
canvas.height = LOGICAL_HEIGHT * dpr;
canvas.style.width = `${LOGICAL_WIDTH}px`; // 邏輯尺寸(CSS)
canvas.style.height = `${LOGICAL_HEIGHT}px`;
ctx.scale(dpr, dpr); // 坐標系仍按邏輯尺寸繪制
LOGICAL_WIDTH=600
/GRID_SIZE=20
→CELL_SIZE=30
。- 在 2× 屏上,實際像素會是 1200×1200,但我們仍然用 600×600 的坐標系繪制,既清晰又好算。
5. 核心建模:網格、蛇、食物、狀態機
5.1 網格與單位
- 網格 20×20,單位為格(cell);渲染時乘
CELL_SIZE
得到像素位置。 - 你使用了淺色網格線(
rgba(255,255,255,0.1)
)作為背景輔助,這是一個很實用的視覺調試手段。
5.2 蛇(Snake)
- 用數組
snake
表示蛇,從snake[0]
到snake[snake.length-1]
依次為頭到尾。 - 每個元素是
{x, y}
的格子坐標。 - 移動:復制一份
head
,根據方向x±1
或y±1
,再unshift
到數組前端;如果沒吃到食物,pop()
尾巴(這就是“向前移動一格”的直覺實現)。
5.3 食物(Food)
generateFood()
隨機選格,循環重試直到不與蛇體重疊。- 對于極端情況(蛇很長快滿屏),這套策略也能靠多次抽樣找到空位;若要更保險,可加入最大重試次數 + 回退(例如掃描第一個空格)。
5.4 狀態機(Game State)
paused
→running
→gameOver
三態。- 開始/暫停按鈕切換
paused ? running
,Game Over 僅在碰撞時進入。 statusText + 狀態燈 + 按鈕文案
同步反饋當前狀態。
6. 主循環:requestAnimationFrame + Tick 節流
游戲循環由 requestAnimationFrame(drawGame)
驅動,但蛇的邏輯步進用固定 Tick 控制(tickInterval
)。核心片段:
if (gameState === 'running') {if (!lastTickTime) lastTickTime = timestamp;const elapsed = timestamp - lastTickTime;if (elapsed >= tickInterval) {lastTickTime = timestamp;updateGame();canChangeDirection = true;}
}
兩個點特別關鍵:
-
時間驅動而不是幀驅動
不同電腦的幀率差異很大,但我們希望“每 N 毫秒前進一格”,這就是“基于時間”的 Tick。 -
canChangeDirection
防抖
在一次邏輯步完成之前,禁止再次改向,避免一幀內多次按鍵導致“瞬間 180° 反向”的非法移動。
7. 碰撞檢測:邊界與自身
7.1 撞墻
- 非穿墻模式:只要
head.x/y
越界(<0 或 ≥GRID_SIZE)直接gameOver()
。 - 穿墻模式:越界則從另一側出現(如
x<0 → x=GRID_SIZE-1
),讓玩家體驗更自由。
7.2 撞自己
- 在把新頭
unshift
之前,先用一個循環與當前蛇身比較坐標,相等即 Game Over。 - 這里的復雜度是 O(n),在 20×20 網格里瓶頸不明顯;如果擴展到大地圖,可以考慮用
Set
(key =x#y
)實現 O(1) 查詢。
8. 得分、提速與難度
- 每吃一個食物 +10 分;每達到 50 分 的整數倍觸發
increaseSpeed()
。 increaseSpeed()
逐步把tickInterval
每次減少20ms
,但不低于 80ms 的安全下限。- 下拉框設置初始難度(200/150/100ms),只影響起步速度,后續仍按分數加速。
這種“有限加速”的節奏設計能讓玩家感覺逐步緊張但不至于失控。
9. 輸入系統:鍵盤 + 觸控滑動 + 虛擬方向鍵
9.1 鍵盤
- 方向鍵與
WASD
等價;空格暫停/開始;R
重置。 - 方向設置統一走
setDirection(newDirection)
,在此處封裝禁止 180° 與canChangeDirection
的邏輯,避免重復校驗。
9.2 觸控滑動
touchstart
記錄起點;touchend
計算dx/dy
,絕對值較大者代表滑動方向,并設置一個閾值(50px)過濾誤觸。- 移動端滑動比點擊按鈕更自然,尤其在全屏 Canvas 上。
9.3 虛擬方向鍵
- 在
<768px
顯示,由 3×3 網格排布四個方向按鈕構成。 - 綁定
touchstart
即可,不爭搶touchend
,手感更靈敏。
小建議:如果想進一步提升移動端操控,可給虛擬方向鍵加入按下/抬起的視覺反饋(例如
scale(0.96)
+ 投影增強)。
10. 視聽與可達性:音效、狀態可見、模態反饋
10.1 Web Audio 小音效(可降級)
你用原生 Web Audio 生成了“吃到食物(sine,高音短促)”與“失敗(sawtooth,低音略長)”。優點是體積 0、無資源加載。
瀏覽器未授權或不支持時靜默降級,不會阻塞游戲。
10.2 狀態可視化
- 文本 + 狀態燈(顏色切換)雙重反饋。
開始/暫停
文案與狀態保持一致,減少認知負擔。- Game Over 用自定義模態層(非
alert
),用戶體驗更柔和,還能在面板上放“重新開始”。
11. 存檔:localStorage 的最高分
- 啟動時
loadHighScore()
讀取,Game Over 時saveHighScore()
更新。 - 只在分數超過歷史時寫入,避免無謂的存取。
進階:你可以把難度、穿墻、是否靜音也一并持久化,做到“偏好記憶”。
12. 性能與邊界:穩定運行的小技巧
-
標簽頁切換自動暫停
當前版本在visibilitychange
未處理。如果實現:- 不要在隱藏時繼續 RAF + Tick;主動暫停并在信息欄提示“已自動暫停”。
參考代碼:
document.addEventListener('visibilitychange', () => {if (document.hidden && gameState === 'running') {startPauseGame(); // 觸發暫停statusTextElement.textContent = '已自動暫停';} });
-
Resize 的冪等性
你在resize
時調用initGame()
,這會重置蛇與分數。文案已有“適配新窗口”的注釋,但對玩家不友好。
更好的做法是:僅重配畫布與縮放,不改動游戲狀態與數據:function resizeCanvasOnly() {const dpr = window.devicePixelRatio || 1;ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置 transformcanvas.width = LOGICAL_WIDTH * dpr;canvas.height = LOGICAL_HEIGHT * dpr;canvas.style.width = `${LOGICAL_WIDTH}px`;canvas.style.height = `${LOGICAL_HEIGHT}px`;ctx.scale(dpr, dpr); } window.addEventListener('resize', resizeCanvasOnly);
-
自碰撞的優化
當前 O(n) 遍歷在 20×20 內完全足夠。若你把地圖放大,可用Set
存x#y
哈希來 O(1) 查詢。 -
渲染順序與清屏
你已正確使用clearRect
+ “網格→食物→蛇”的順序。若加粒子特效,注意在蛇之后繪制,保證覆蓋關系。
13. 常見 Bug 與排查清單
-
按住按鍵快速抖動,蛇突然反向?
確認canChangeDirection
是否只在updateGame()
后釋放;不要在keydown
處反復釋放。 -
移動端滑動不生效或誤觸嚴重?
檢查touchstart/touchend.preventDefault()
是否設置;增大滑動閾值(如 70px);避免與頁面滾動沖突(Canvas 容器設置touch-action: none
)。 -
DPR 下線條斷裂或模糊?
使用偶數像素或對半像素線進行偏移(本項目用淺色網格,影響不大)。 -
最高分沒有保存?
確認瀏覽器隱私模式下localStorage
是否可用;或被跨域頁面嵌套導致安全限制。
14. 可擴展清單(附思路與實現要點)
14.1 反彈墻模式(Bumper)
- 玩法:撞墻不死,方向向內反彈(左右墻翻轉
dx
,上下墻翻轉dy
),但扣 1 分或扣生命。 - 實現:把越界處的判斷從
gameOver()
改為反向修正,同時加一個lives
或score--
。
14.2 多食物與特殊物品
- 普通食物:+10 分;
- 金色食物:限時出現,+30 分,吃到播放不同音效;
- 毒蘋果:吃到減速或扣分;
- 實現:維護食物數組與
type
字段,渲染時區分顏色與大小。
14.3 關卡與任務
- 目標:在 60 秒內達到 200 分;
- 限制:禁止穿墻、限定初始難度;
- 獎勵:關卡完成后解鎖皮膚或粒子特效。
14.4 皮膚與主題
-
預置主題對象:
const themes = {classic: { bg:'#000', snake:'#4CAF50', head:'#FFC107', food:'#F44336' },neon: { bg:'#0a0a0a', snake:'#00e5ff', head:'#b388ff', food:'#ff6e40' }, };
-
在渲染函數里用主題色,配合下拉或按鈕切換。
14.5 錄像與回放(Ghost)
- 記錄每個 Tick 的方向與食物坐標,生成“幽靈蛇”數據。
- 回放時在同一張地圖重放路徑,玩家可挑戰自己最佳路線。
15. 關鍵代碼走讀與講解
下面選取幾個代表性的代碼片段做解構說明(與原代碼保持一致/等價),幫助你在文章或講座里逐步帶讀。
15.1 初始化與重置
function initGame() {const dpr = window.devicePixelRatio || 1;canvas.width = LOGICAL_WIDTH * dpr;canvas.height = LOGICAL_HEIGHT * dpr;canvas.style.width = `${LOGICAL_WIDTH}px`;canvas.style.height = `${LOGICAL_HEIGHT}px`;ctx.scale(dpr, dpr);loadHighScore();resetGame();
}function resetGame() {snake = [];for (let i = INITIAL_SNAKE_LENGTH - 1; i >= 0; i--) {snake.push({ x: i, y: Math.floor(GRID_SIZE / 2) });}direction = nextDirection = 'right';generateFood();score = 0;updateScore();gameState = 'paused';statusTextElement.textContent = '已暫停';statusIndicatorElement.classList.remove('running');startPauseBtn.textContent = '開始';setDifficulty(difficultySelectElement.value);gameMessage.style.display = 'none';drawGame(); // 先繪一幀靜態畫面
}
解讀:
snake
從中線開始,向右排 3 格;- 先繪制一幀靜態畫面,再等待開始指令;
- 所有 UI 狀態(文本、指示燈、按鈕)與
gameState
一致,是“緊耦合”的必要同步。
15.2 更新一步(游戲邏輯)
function updateGame() {direction = nextDirection; // 只在 Tick 邊界切換方向const head = { ...snake[0] }; // 拷貝頭部switch (direction) {case 'up': head.y -= 1; break;case 'down': head.y += 1; break;case 'left': head.x -= 1; break;case 'right': head.x += 1; break;}if (!wallThroughMode) {if (head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE) {gameOver(); return;}} else {if (head.x < 0) head.x = GRID_SIZE - 1;else if (head.x >= GRID_SIZE) head.x = 0;if (head.y < 0) head.y = GRID_SIZE - 1;else if (head.y >= GRID_SIZE) head.y = 0;}for (let segment of snake) {if (segment.x === head.x && segment.y === head.y) {gameOver(); return;}}snake.unshift(head);if (head.x === food.x && head.y === food.y) {score += 10;updateScore();playSound('eat');generateFood();if (score % SCORE_THRESHOLD === 0) increaseSpeed();} else {snake.pop();}
}
解讀:
- 方向的延遲生效(Tick 邊界切換)配合
canChangeDirection
,保證沒有“同幀多次拐彎”的競態。 - 穿墻與越界死亡兩個分支互斥,邏輯清晰;
- “吃到食物”才增長,否則移除尾巴維持長度不變。
15.3 觸控滑動的方向判定
canvas.addEventListener('touchend', (e) => {if (gameState === 'gameOver') return;e.preventDefault();if (!touchStartX || !touchStartY) return;const touch = e.changedTouches[0];const dx = touch.clientX - touchStartX;const dy = touch.clientY - touchStartY;if (Math.abs(dx) > Math.abs(dy)) {if (dx > 50) setDirection('right');else if (dx < -50) setDirection('left');} else {if (dy > 50) setDirection('down');else if (dy < -50) setDirection('up');}touchStartX = 0;touchStartY = 0;
});
解讀:
- 方向由主軸位移決定(橫向或縱向);
- 50px 閾值過濾輕微滑動;
preventDefault()
避免瀏覽器把滑動當滾動處理。
16. 代碼打磨:兩處值得改進的小細節
-
音頻上下文復用
每次playSound
都創建AudioContext
成本較高,且部分瀏覽器限制實例數量。可以外部維護一個惰性單例:let audioCtx; function getAudioCtx() {if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();return audioCtx; } function playSound(type) {if (!soundEnabled) return;try {const audioContext = getAudioCtx();const osc = audioContext.createOscillator();const gain = audioContext.createGain();osc.connect(gain); gain.connect(audioContext.destination);// ...同原邏輯} catch (e) { /* 靜默 */ } }
-
重繪請求的冪等性
drawGame()
內部會requestAnimationFrame(drawGame)
,啟動時你又在start()
調了drawGame()
一次是對的,但要避免重復綁定導致多重 RAF(當前代碼沒問題,因為調用鏈單一)。如果以后抽取模塊,注意只在唯一入口里開始 RAF。
總結
把這套工程化骨架掌握住,你基本就擁有了前端小游戲的“模板思維”:
- 數據結構先行(網格→蛇→食物);
- 狀態機護航(paused/running/gameOver);
- 時間驅動循環(Tick 節流);
- 交互合流(鍵盤/觸控/虛擬鍵統一到
setDirection
); - 體驗閉環(狀態燈/模態/音效/存檔);
- 漸進增強(DPR/移動端)。