☰
用Canvas和JavaScript实现猫咪狂奔记:2D跑酷小游戏开发实战
2026/10/21 7:44:05 网站建设 项目流程

做小游戏练手的人,最容易卡在哪一步?不是算法,不是框架,而是角色动起来之后“假”。很多初学者写的 Canvas 小游戏,里面的猫咪要么在地上平移,要么四只脚像电动玩具一样同步摆动,怎么看都别扭。问题不在于创意,也不在于美术能力,而在于没有理解“跑动循环”这件事。

这篇文章想用“猫咪狂奔记”这个项目,把游戏角色动画的最小可行路径讲清楚。我们会参考 Simon's Cat 那种黑白简笔画风格,用纯 HTML5 Canvas 加原生 JavaScript,从零实现一个会跑、会跳、能躲避障碍物的横向跑酷小游戏。项目不依赖任何第三方库,一个浏览器加一个文本编辑器就能跑通。

Simon's Cat 是英国动画师 Simon Tofield 创作的系列动画短片,主角是一只黑白简笔画风格的猫咪。据公开资料,这个系列的核心特色是:线条极简,动态极丰富。这两种特质放到技术项目里非常讨喜——不需要准备美术资源,不需要引入骨骼动画工具,只需要理解几个关键帧和一点游戏状态机的设计。

我的判断是:这个项目的技术含量不在“画一只猫”,而在跑动循环、状态切换和碰撞检测这三件事。把这三件事想清楚,你不仅能做出猫咪狂奔记,还能迁移到任何 2D 跑酷游戏上。下面从问题开始,一步一步把整个项目拆开。

1. 这篇文章真正要解决的问题

1.1 新手练手项目的三个坑

很多初学者第一次做小游戏,通常会踩三个坑。

第一个坑是选题太大。一上来就想做“横版动作游戏”,结果角色、地图、敌人、战斗系统全堆在一起,代码写到一半就失去了方向。第二个坑是素材卡住。试图先找一只可爱的猫咪图片,再找背景、找音效、找动画序列帧,最后发现素材的时间比写代码还多,而且图片风格还不统一。第三个坑是只做“静态展示”。角色明明能动了,但只是简单的 translate 平移,没有跳跃、没有障碍、没有碰撞、没有游戏状态,玩起来没有反馈,也就失去了“游戏”的意义。

猫咪狂奔记可以同时绕开这三个坑。它的玩法足够小:一个角色,一条跑道,一组障碍物。它的美术可以完全用 Canvas 绘制,不需要外部图片。它又具备一个完整游戏的核心要素:动画循环、物理跳跃、碰撞判定、分数增长、重新开始。

1.2 为什么是“猫咪狂奔记”而不是别的

跑酷类小游戏是练习 2D 游戏开发最好的入门题目之一。它看似简单,却包含了几乎所有游戏都会遇到的基础问题:

  • 如何用 requestAnimationFrame 驱动游戏主循环;
  • 如何让角色的动画帧随时间切换;
  • 如何处理跳跃重力和落地判定;
  • 如何生成障碍物并基于 AABB 做碰撞检测;
  • 如何管理 READY、RUNNING、OVER 这三个游戏状态;
  • 如何在游戏结束后重置所有数据。

这些能力是通用的。今天做的是猫咪狂奔记,明天换个角色,改成小狗、太空人、火箭,逻辑几乎不用动。这也是我推荐大家用这个题材练手的原因:它把“游戏”这个词拆成了一个个可以独立理解的小问题。

1.3 读完这篇文章能做到什么

读完这篇文章,你会得到一份完整的可运行代码,并且能说清楚每一行背后的设计意图。具体来说:

  • 你能在浏览器中打开一个自绘简笔画风格的跑酷小游戏;
  • 你能理解跑动循环中四帧姿态的基本原理;
  • 你能掌握 Canvas 2D 游戏中最常用的状态机模式;
  • 你会在遇到常见错误时,知道先看哪行代码、打印什么信息;
  • 你会得到一份针对“小游戏项目”的工程化建议,而不是只学会复制粘贴。

2. Simon's Cat 画风的技术拆解

2.1 简笔画风格的三要素

Simon's Cat 的视觉风格可以拆成三个技术要素:线条、留白、节奏。

线条方面,角色由黑色轮廓线构成,几乎不使用复杂的渐变和阴影,线条粗细变化也不大。这种风格对应到 Canvas 里,就是一条strokeStyle = '#222'和lineWidth = 3的设置。留白方面,背景和角色之间保持高对比度,角色不会淹没在复杂场景里。节奏方面,动画短片常用“安静铺垫 + 突然爆发”的剪辑方式,小游戏里对应的就是“待机状态 + 突然开始奔跑跳跃”。

这三个要素对我们做游戏有一个直接启发:美术成本可以压到极低,但动态表现必须足够清晰。这非常符合独立开发者和学习者的需求。

2.2 从画风到 Canvas 实现策略

在 Canvas 中实现这种风格,不需要精灵图,也不需要使用图片资源。一只简笔画猫咪可以用几个基础图形组合出来:

  • ellipse画身体;
  • arc画头部;
  • 两条直线加折线画耳朵;
  • quadraticCurveTo画尾巴;
  • 四条直线画腿部。

用这种方式绘制的角色,体积小、加载快、缩放不模糊,而且代码完全可控。如果你觉得这只猫画得不够好看,可以随时调整坐标和线条参数,不需要重新准备素材。这种“程序化绘制”的思路,是 Canvas 小游戏相比图片素材方案的一个重要优势。

2.3 版权边界与学习姿态

需要提醒的是,Simon's Cat 是一个受版权保护的动画 IP。我们参考的是“黑白简笔画猫咪”这种风格方向,并自己用 Canvas 绘制角色,这属于学习和技术演示的范畴。但如果把原片的截图、官方素材、角色精确造型直接拿进游戏发布,就会涉及版权问题。做技术练习时保留敬意,做正式产品时替换为原创素材,这是比较稳妥的姿态。

3. 技术选型与整体架构

3.1 为什么用纯 Canvas 而不是游戏引擎

目前做小游戏有很多选择:Phaser、Unity、Cocos Creator,甚至还有各种微信小游戏框架。那为什么这个项目坚持用纯 HTML5 Canvas 加原生 JavaScript?

核心原因是降低认知负担。用游戏引擎时,你同时在学习引擎的组件系统、资源管理、场景编辑器和脚本生命周期,这些知识当然有用,但它们会掩盖 2D 游戏最底层的运行机制。而用原生 Canvas,游戏主循环、状态管理、碰撞检测全都要亲手写,理解反而更扎实。当你已经清楚一个游戏是怎么从零转起来之后,再去用 Phaser 或 Unity,速度和深度都会不一样。

3.2 游戏的核心模块

整个游戏可以拆成四个模块:

模块职责对应文件
入口页面创建 Canvas,加载脚本index.html
猫咪精灵类绘制角色、跑动动画、跳跃物理js/cat.js
障碍物类绘制障碍物、移动、碰撞盒js/obstacle.js
游戏主逻辑主循环、状态机、障碍物生成、分数js/game.js

这种拆分方式并不复杂,但已经具备小项目的模块化雏形。把“绘制角色的代码”和“控制游戏状态的代码”分开,排查问题时会轻松很多。

3.3 项目目录结构

项目的文件结构非常简洁,适合直接对照着建:

simons-cat-run/ ├── index.html └── js/ ├── cat.js ├── obstacle.js └── game.js

4. 环境准备与前置条件

这个项目对环境的要求低到几乎可以忽略,但这恰恰是它的优点:你没有理由因为环境问题卡住。

4.1 运行环境

你需要准备的内容如下:

  • 一个现代浏览器,推荐 Chrome 或 Edge,版本没有硬性要求,需要支持 Canvas 2D 和 ES6 class;
  • 一个文本编辑器,VS Code、WebStorm、记事本都可以;
  • 不需要 Node.js,不需要 npm,不需要任何构建工具。

代码中使用的是 ES6 的class语法,这一点在 2017 年之后的浏览器中都得到支持。如果你在比较旧的浏览器中看到“class is not defined”之类的错误,替换一个新浏览器即可。

4.2 可选:启动本地静态服务器

直接双击index.html一般可以正常运行。但如果你在浏览器控制台看到跨域或加载错误,最简单的办法是启动一个本地静态服务器。

使用 Python 的写法如下:

# 在项目根目录执行,启动一个 8000 端口的静态服务 python -m http.server 8000

然后访问:

http://localhost:8000

如果你用 VS Code,安装 Live Server 插件后,在index.html上右键选择“Open with Live Server”也行。本地服务器不是必须的,但养成这个习惯对后续调试大有好处。

4.3 画布尺寸约定

为了后续所有坐标和碰撞逻辑能对上,我们约定整个游戏在一个 800 x 450 的画布中运行,地面高度定位在 y = 380。地面以上的空间是角色和障碍物的活动区域,地面以下不绘制任何对象。这个约定会在多个文件中反复出现,统一放到 game.js 头部定义即可。

5. 核心流程拆解

在贴完整代码之前,先把核心流程拆清楚。这些概念是后面理解代码的关键。

5.1 游戏主循环:requestAnimationFrame 与 dt

游戏和普通网页的最大区别,是游戏需要持续不断地更新和重绘。requestAnimationFrame是浏览器提供的定时重绘机制,它会在每一帧刷新前调用回调函数,频率通常在 60 次每秒左右。

主循环中一定要计算dt,也就是“距上一帧过去了多少秒”。如果不使用dt,而是每帧固定移动 5 个像素,那么 60Hz 屏幕和 120Hz 屏幕上的游戏速度会完全不同。使用dt后,每帧移动距离是速度 * dt,游戏在不同刷新率下表现才一致。

5.2 游戏状态机:READY / RUNNING / OVER

小游戏虽然玩法简单,也必须有状态机。这个项目只定义三个状态:

状态含义触发方式
READY待开始页面加载后
RUNNING游戏运行中玩家按下空格或点击屏幕
OVER游戏结束猫咪与障碍物碰撞

状态机决定了哪些逻辑能执行。比如在 READY 状态下,障碍物不生成,猫不移动;在 RUNNING 状态下,分数正常增长;在 OVER 状态下,猫咪停止动作,玩家可以重新开始。如果不做状态机,逻辑会纠缠在一起,按下空格时可能同时触发“开始”和“跳跃”,很难控制。

5.3 猫咪跑动动画:帧切换

角色动画最基础的形式是逐帧切换。我们把猫咪的跑步拆分成了四个姿态,每 80 毫秒切换一帧,四帧循环。帧切换的核心就是一个计时器:

this.frameTimer += dt * 1000; if (this.frameTimer >= this.frameDuration) { this.frameTimer -= this.frameDuration; this.frame = (this.frame + 1) % 4; }

关键是最后一行(this.frame + 1) % 4,当帧号到达 3 后会回到 0,形成循环。四个姿态的腿部位置不同,视觉上就产生了奔跑感。

5.4 跳跃与重力

跳跃的实现比很多人想象中简单,本质是“给角色一个向上的初速度,然后每帧叠加重力”。每一帧的位移公式为:

this.y += this.vy * dt; this.vy += 980 * dt;

当this.y回到地面高度时,把角色放回地面并清零垂直速度。这里的 980 是重力加速度的简化值,单位是像素/秒²,可以调节手感。数值越大,跳跃下落越快;数值越小,角色飘在空中越久。

5.5 障碍物生成与碰撞检测

障碍物从画布右侧生成,以恒定速度向左移动。生成时机由一个计时器控制,间隔随机,避免玩家预判。

碰撞检测使用的是 AABB(轴对齐包围盒)方式。也就是把角色和障碍物都看成矩形,检查两个矩形是否重叠。四条件判断听起来繁琐,但实际上就是把“右边界在对方左边界的右侧”和“左边界在对方右边界的左侧”等条件组合起来:

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 );

只要四个条件同时成立,说明两个矩形重叠,判断碰撞发生。

6. 完整示例代码实现

下面给出完整的可运行代码。建议按文件路径逐个创建,保持代码结构一致。

6.1 入口页面:index.html

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 id="game" width="800" height="450"></canvas> <!-- 注意加载顺序:cat.js 和 obstacle.js 必须在 game.js 之前 --> <script src="js/cat.js"></script> <script src="js/obstacle.js"></script> <script src="js/game.js"></script> </body> </html>

这段 HTML 的关键点只有一个:脚本加载顺序。game.js中使用了Cat和Obstacle两个类,所以这两个文件必须先加载。如果顺序写反,浏览器会在创建对象时报错Cat is not defined。

6.2 猫咪精灵类:js/cat.js

// 文件路径:js/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; // 初速度,向上为负 } } 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'; // 说明:这里的坐标值是在 800x450 画布下调试出来的, // 如果你调整了画布尺寸,需要重新调身体、头、腿的位置。 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(); // 四种跑动姿态,分别控制四条腿的抬起/落下 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 } ]; // 跳跃时腿收起,不播放跑步姿态 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询