貓咪狂奔記:2D跑酷小游戲開發(fā)實戰(zhàn))
做小游戲練手的人最容易卡在哪一步不是算法不是框架而是角色動起來之后“假”。很多初學(xué)者寫的 Canvas 小游戲里面的貓咪要么在地上平移要么四只腳像電動玩具一樣同步擺動怎么看都別扭。問題不在于創(chuàng)意也不在于美術(shù)能力而在于沒有理解“跑動循環(huán)”這件事。這篇文章想用“貓咪狂奔記”這個項目把游戲角色動畫的最小可行路徑講清楚。我們會參考 Simons Cat 那種黑白簡筆畫風(fēng)格用純 HTML5 Canvas 加原生 JavaScript從零實現(xiàn)一個會跑、會跳、能躲避障礙物的橫向跑酷小游戲。項目不依賴任何第三方庫一個瀏覽器加一個文本編輯器就能跑通。Simons Cat 是英國動畫師 Simon Tofield 創(chuàng)作的系列動畫短片主角是一只黑白簡筆畫風(fēng)格的貓咪。據(jù)公開資料這個系列的核心特色是線條極簡動態(tài)極豐富。這兩種特質(zhì)放到技術(shù)項目里非常討喜——不需要準(zhǔn)備美術(shù)資源不需要引入骨骼動畫工具只需要理解幾個關(guān)鍵幀和一點游戲狀態(tài)機(jī)的設(shè)計。我的判斷是這個項目的技術(shù)含量不在“畫一只貓”而在跑動循環(huán)、狀態(tài)切換和碰撞檢測這三件事。把這三件事想清楚你不僅能做出貓咪狂奔記還能遷移到任何 2D 跑酷游戲上。下面從問題開始一步一步把整個項目拆開。1. 這篇文章真正要解決的問題1.1 新手練手項目的三個坑很多初學(xué)者第一次做小游戲通常會踩三個坑。第一個坑是選題太大。一上來就想做“橫版動作游戲”結(jié)果角色、地圖、敵人、戰(zhàn)斗系統(tǒng)全堆在一起代碼寫到一半就失去了方向。第二個坑是素材卡住。試圖先找一只可愛的貓咪圖片再找背景、找音效、找動畫序列幀最后發(fā)現(xiàn)素材的時間比寫代碼還多而且圖片風(fēng)格還不統(tǒng)一。第三個坑是只做“靜態(tài)展示”。角色明明能動了但只是簡單的 translate 平移沒有跳躍、沒有障礙、沒有碰撞、沒有游戲狀態(tài)玩起來沒有反饋也就失去了“游戲”的意義。貓咪狂奔記可以同時繞開這三個坑。它的玩法足夠小一個角色一條跑道一組障礙物。它的美術(shù)可以完全用 Canvas 繪制不需要外部圖片。它又具備一個完整游戲的核心要素動畫循環(huán)、物理跳躍、碰撞判定、分?jǐn)?shù)增長、重新開始。1.2 為什么是“貓咪狂奔記”而不是別的跑酷類小游戲是練習(xí) 2D 游戲開發(fā)最好的入門題目之一。它看似簡單卻包含了幾乎所有游戲都會遇到的基礎(chǔ)問題如何用 requestAnimationFrame 驅(qū)動游戲主循環(huán)如何讓角色的動畫幀隨時間切換如何處理跳躍重力和落地判定如何生成障礙物并基于 AABB 做碰撞檢測如何管理 READY、RUNNING、OVER 這三個游戲狀態(tài)如何在游戲結(jié)束后重置所有數(shù)據(jù)。這些能力是通用的。今天做的是貓咪狂奔記明天換個角色改成小狗、太空人、火箭邏輯幾乎不用動。這也是我推薦大家用這個題材練手的原因它把“游戲”這個詞拆成了一個個可以獨立理解的小問題。1.3 讀完這篇文章能做到什么讀完這篇文章你會得到一份完整的可運行代碼并且能說清楚每一行背后的設(shè)計意圖。具體來說你能在瀏覽器中打開一個自繪簡筆畫風(fēng)格的跑酷小游戲你能理解跑動循環(huán)中四幀姿態(tài)的基本原理你能掌握 Canvas 2D 游戲中最常用的狀態(tài)機(jī)模式你會在遇到常見錯誤時知道先看哪行代碼、打印什么信息你會得到一份針對“小游戲項目”的工程化建議而不是只學(xué)會復(fù)制粘貼。2. Simons Cat 畫風(fēng)的技術(shù)拆解2.1 簡筆畫風(fēng)格的三要素Simons Cat 的視覺風(fēng)格可以拆成三個技術(shù)要素線條、留白、節(jié)奏。線條方面角色由黑色輪廓線構(gòu)成幾乎不使用復(fù)雜的漸變和陰影線條粗細(xì)變化也不大。這種風(fēng)格對應(yīng)到 Canvas 里就是一條strokeStyle #222和lineWidth 3的設(shè)置。留白方面背景和角色之間保持高對比度角色不會淹沒在復(fù)雜場景里。節(jié)奏方面動畫短片常用“安靜鋪墊 突然爆發(fā)”的剪輯方式小游戲里對應(yīng)的就是“待機(jī)狀態(tài) 突然開始奔跑跳躍”。這三個要素對我們做游戲有一個直接啟發(fā)美術(shù)成本可以壓到極低但動態(tài)表現(xiàn)必須足夠清晰。這非常符合獨立開發(fā)者和學(xué)習(xí)者的需求。2.2 從畫風(fēng)到 Canvas 實現(xiàn)策略在 Canvas 中實現(xiàn)這種風(fēng)格不需要精靈圖也不需要使用圖片資源。一只簡筆畫貓咪可以用幾個基礎(chǔ)圖形組合出來ellipse畫身體arc畫頭部兩條直線加折線畫耳朵quadraticCurveTo畫尾巴四條直線畫腿部。用這種方式繪制的角色體積小、加載快、縮放不模糊而且代碼完全可控。如果你覺得這只貓畫得不夠好看可以隨時調(diào)整坐標(biāo)和線條參數(shù)不需要重新準(zhǔn)備素材。這種“程序化繪制”的思路是 Canvas 小游戲相比圖片素材方案的一個重要優(yōu)勢。2.3 版權(quán)邊界與學(xué)習(xí)姿態(tài)需要提醒的是Simons Cat 是一個受版權(quán)保護(hù)的動畫 IP。我們參考的是“黑白簡筆畫貓咪”這種風(fēng)格方向并自己用 Canvas 繪制角色這屬于學(xué)習(xí)和技術(shù)演示的范疇。但如果把原片的截圖、官方素材、角色精確造型直接拿進(jìn)游戲發(fā)布就會涉及版權(quán)問題。做技術(shù)練習(xí)時保留敬意做正式產(chǎn)品時替換為原創(chuàng)素材這是比較穩(wěn)妥的姿態(tài)。3. 技術(shù)選型與整體架構(gòu)3.1 為什么用純 Canvas 而不是游戲引擎目前做小游戲有很多選擇Phaser、Unity、Cocos Creator甚至還有各種微信小游戲框架。那為什么這個項目堅持用純 HTML5 Canvas 加原生 JavaScript核心原因是降低認(rèn)知負(fù)擔(dān)。用游戲引擎時你同時在學(xué)習(xí)引擎的組件系統(tǒng)、資源管理、場景編輯器和腳本生命周期這些知識當(dāng)然有用但它們會掩蓋 2D 游戲最底層的運行機(jī)制。而用原生 Canvas游戲主循環(huán)、狀態(tài)管理、碰撞檢測全都要親手寫理解反而更扎實。當(dāng)你已經(jīng)清楚一個游戲是怎么從零轉(zhuǎn)起來之后再去用 Phaser 或 Unity速度和深度都會不一樣。3.2 游戲的核心模塊整個游戲可以拆成四個模塊模塊職責(zé)對應(yīng)文件入口頁面創(chuàng)建 Canvas加載腳本index.html貓咪精靈類繪制角色、跑動動畫、跳躍物理js/cat.js障礙物類繪制障礙物、移動、碰撞盒js/obstacle.js游戲主邏輯主循環(huán)、狀態(tài)機(jī)、障礙物生成、分?jǐn)?shù)js/game.js這種拆分方式并不復(fù)雜但已經(jīng)具備小項目的模塊化雛形。把“繪制角色的代碼”和“控制游戲狀態(tài)的代碼”分開排查問題時會輕松很多。3.3 項目目錄結(jié)構(gòu)項目的文件結(jié)構(gòu)非常簡潔適合直接對照著建simons-cat-run/ ├── index.html └── js/ ├── cat.js ├── obstacle.js └── game.js4. 環(huán)境準(zhǔn)備與前置條件這個項目對環(huán)境的要求低到幾乎可以忽略但這恰恰是它的優(yōu)點你沒有理由因為環(huán)境問題卡住。4.1 運行環(huán)境你需要準(zhǔn)備的內(nèi)容如下一個現(xiàn)代瀏覽器推薦 Chrome 或 Edge版本沒有硬性要求需要支持 Canvas 2D 和 ES6 class一個文本編輯器VS Code、WebStorm、記事本都可以不需要 Node.js不需要 npm不需要任何構(gòu)建工具。代碼中使用的是 ES6 的class語法這一點在 2017 年之后的瀏覽器中都得到支持。如果你在比較舊的瀏覽器中看到“class is not defined”之類的錯誤替換一個新瀏覽器即可。4.2 可選啟動本地靜態(tài)服務(wù)器直接雙擊index.html一般可以正常運行。但如果你在瀏覽器控制臺看到跨域或加載錯誤最簡單的辦法是啟動一個本地靜態(tài)服務(wù)器。使用 Python 的寫法如下# 在項目根目錄執(zhí)行啟動一個 8000 端口的靜態(tài)服務(wù) python -m http.server 8000然后訪問http://localhost:8000如果你用 VS Code安裝 Live Server 插件后在index.html上右鍵選擇“Open with Live Server”也行。本地服務(wù)器不是必須的但養(yǎng)成這個習(xí)慣對后續(xù)調(diào)試大有好處。4.3 畫布尺寸約定為了后續(xù)所有坐標(biāo)和碰撞邏輯能對上我們約定整個游戲在一個 800 x 450 的畫布中運行地面高度定位在 y 380。地面以上的空間是角色和障礙物的活動區(qū)域地面以下不繪制任何對象。這個約定會在多個文件中反復(fù)出現(xiàn)統(tǒng)一放到 game.js 頭部定義即可。5. 核心流程拆解在貼完整代碼之前先把核心流程拆清楚。這些概念是后面理解代碼的關(guān)鍵。5.1 游戲主循環(huán)requestAnimationFrame 與 dt游戲和普通網(wǎng)頁的最大區(qū)別是游戲需要持續(xù)不斷地更新和重繪。requestAnimationFrame是瀏覽器提供的定時重繪機(jī)制它會在每一幀刷新前調(diào)用回調(diào)函數(shù)頻率通常在 60 次每秒左右。主循環(huán)中一定要計算dt也就是“距上一幀過去了多少秒”。如果不使用dt而是每幀固定移動 5 個像素那么 60Hz 屏幕和 120Hz 屏幕上的游戲速度會完全不同。使用dt后每幀移動距離是速度 * dt游戲在不同刷新率下表現(xiàn)才一致。5.2 游戲狀態(tài)機(jī)READY / RUNNING / OVER小游戲雖然玩法簡單也必須有狀態(tài)機(jī)。這個項目只定義三個狀態(tài)狀態(tài)含義觸發(fā)方式READY待開始頁面加載后RUNNING游戲運行中玩家按下空格或點擊屏幕OVER游戲結(jié)束貓咪與障礙物碰撞狀態(tài)機(jī)決定了哪些邏輯能執(zhí)行。比如在 READY 狀態(tài)下障礙物不生成貓不移動在 RUNNING 狀態(tài)下分?jǐn)?shù)正常增長在 OVER 狀態(tài)下貓咪停止動作玩家可以重新開始。如果不做狀態(tài)機(jī)邏輯會糾纏在一起按下空格時可能同時觸發(fā)“開始”和“跳躍”很難控制。5.3 貓咪跑動動畫幀切換角色動畫最基礎(chǔ)的形式是逐幀切換。我們把貓咪的跑步拆分成了四個姿態(tài)每 80 毫秒切換一幀四幀循環(huán)。幀切換的核心就是一個計時器this.frameTimer dt * 1000; if (this.frameTimer this.frameDuration) { this.frameTimer - this.frameDuration; this.frame (this.frame 1) % 4; }關(guān)鍵是最后一行(this.frame 1) % 4當(dāng)幀號到達(dá) 3 后會回到 0形成循環(huán)。四個姿態(tài)的腿部位置不同視覺上就產(chǎn)生了奔跑感。5.4 跳躍與重力跳躍的實現(xiàn)比很多人想象中簡單本質(zhì)是“給角色一個向上的初速度然后每幀疊加重力”。每一幀的位移公式為this.y this.vy * dt; this.vy 980 * dt;當(dāng)this.y回到地面高度時把角色放回地面并清零垂直速度。這里的 980 是重力加速度的簡化值單位是像素/秒2可以調(diào)節(jié)手感。數(shù)值越大跳躍下落越快數(shù)值越小角色飄在空中越久。5.5 障礙物生成與碰撞檢測障礙物從畫布右側(cè)生成以恒定速度向左移動。生成時機(jī)由一個計時器控制間隔隨機(jī)避免玩家預(yù)判。碰撞檢測使用的是 AABB軸對齊包圍盒方式。也就是把角色和障礙物都看成矩形檢查兩個矩形是否重疊。四條件判斷聽起來繁瑣但實際上就是把“右邊界在對方左邊界的右側(cè)”和“左邊界在對方右邊界的左側(cè)”等條件組合起來return ( this.x catBox.x catBox.width this.x this.width catBox.x this.y catBox.y catBox.height this.y this.height catBox.y );只要四個條件同時成立說明兩個矩形重疊判斷碰撞發(fā)生。6. 完整示例代碼實現(xiàn)下面給出完整的可運行代碼。建議按文件路徑逐個創(chuàng)建保持代碼結(jié)構(gòu)一致。6.1 入口頁面index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title貓咪狂奔記/title style body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #faf7f2; font-family: sans-serif; } canvas { border: 2px solid #222; max-width: 90vw; background: #f5efe3; } /style /head body canvas idgame width800 height450/canvas !-- 注意加載順序cat.js 和 obstacle.js 必須在 game.js 之前 -- script srcjs/cat.js/script script srcjs/obstacle.js/script script srcjs/game.js/script /body /html這段 HTML 的關(guān)鍵點只有一個腳本加載順序。game.js中使用了Cat和Obstacle兩個類所以這兩個文件必須先加載。如果順序?qū)懛礊g覽器會在創(chuàng)建對象時報錯Cat is not defined。6.2 貓咪精靈類js/cat.js// 文件路徑j(luò)s/cat.js class Cat { constructor(x, y) { this.x x; this.y y; this.width 70; this.height 60; this.frame 0; this.frameTimer 0; this.frameDuration 80; // 每幀間隔單位毫秒 this.isJumping false; this.vy 0; this.groundY y; } jump() { // 只有在地面時才能起跳避免空中二段跳 if (!this.isJumping) { this.isJumping true; this.vy -420; // 初速度向上為負(fù) } } update(dt) { // 1. 更新跑動動畫幀 this.frameTimer dt * 1000; if (this.frameTimer this.frameDuration) { this.frameTimer - this.frameDuration; this.frame (this.frame 1) % 4; } // 2. 更新跳躍物理 if (this.isJumping) { this.y this.vy * dt; this.vy 980 * dt; // 重力 if (this.y this.groundY) { this.y this.groundY; this.isJumping false; this.vy 0; } } } draw(ctx) { ctx.save(); ctx.strokeStyle #222; ctx.fillStyle #222; ctx.lineWidth 3; ctx.lineCap round; // 說明這里的坐標(biāo)值是在 800x450 畫布下調(diào)試出來的 // 如果你調(diào)整了畫布尺寸需要重新調(diào)身體、頭、腿的位置。 const bodyX this.x; const bodyY this.y - 25; // 尾巴 ctx.beginPath(); ctx.moveTo(bodyX - 10, bodyY - 8); ctx.quadraticCurveTo(bodyX - 30, bodyY - 35, bodyX - 45, bodyY - 10); ctx.stroke(); // 身體 ctx.beginPath(); ctx.ellipse(bodyX, bodyY - 15, 28, 18, 0, 0, Math.PI * 2); ctx.stroke(); // 頭 ctx.beginPath(); ctx.arc(bodyX 40, bodyY - 35, 16, 0, Math.PI * 2); ctx.stroke(); // 耳朵 ctx.beginPath(); ctx.moveTo(bodyX 31, bodyY - 48); ctx.lineTo(bodyX 37, bodyY - 64); ctx.lineTo(bodyX 48, bodyY - 48); ctx.stroke(); // 眼睛 ctx.beginPath(); ctx.arc(bodyX 36, bodyY - 38, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(bodyX 45, bodyY - 38, 2.5, 0, Math.PI * 2); ctx.fill(); // 四種跑動姿態(tài)分別控制四條腿的抬起/落下 const legPoses [ { frontL: 6, frontR: -8, backL: -8, backR: 6 }, { frontL: 10, frontR: -4, backL: -4, backR: 10 }, { frontL: -8, frontR: 6, backL: 6, backR: -8 }, { frontL: -2, frontR: 2, backL: 2, backR: -2 } ]; // 跳躍時腿收起不播放跑步姿態(tài) const pose this.isJumping ? { frontL: -14, frontR: -14, backL: -14, backR: -14 } : legPoses[this.frame]; // 前腿 ctx.beginPath(); ctx.moveTo(bodyX 10, bodyY - 6); ctx.lineTo(bodyX 10, bodyY 26 pose.frontL); ctx.moveTo(bodyX 22, body