ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用原生Canvas手写像素小游戏:从状态机到碰撞的完整实践

2026/10/8 11:24:30 拓冰建站 浏览量
用原生Canvas手写像素小游戏:从状态机到碰撞的完整实践 “caveman” 这个名字在我收藏夹里躺了挺久前阵子终于腾出一个周末把它做完了。它不是什么惊世骇俗的大作就是一个用原生 HTML5 Canvas 写的复古像素小游戏你扮演一个穴居人要在天黑之前从野外收集足够的浆果和木柴躲开恐龙安全钻回自己的洞穴。做完之后我最大的感受是这一类“小到不能再小”的项目反而最容易让人把游戏开发最核心的那几件事吃透状态机怎么组织、主循环怎么跑、碰撞怎么判、像素风怎么画。这篇文章就把整个 caveman 项目的设计、实现和踩坑过程完整写出来想用 Canvas 练手、或者想从零做一个能发布的小游戏的朋友可以直接照着这套思路来。1. 项目命名和玩法边界先把“caveman”想清楚1.1 一个词就是一局游戏天黑之前回到洞穴很多项目之所以烂尾不是技术难而是范围没控制住。caveman 这个词本身就把核心玩法定义清楚了一个穴居人在天黑前回到家。英文里 caveman 这个叫法自带一种原始、笨拙又有点可爱的画面感拿来做游戏主角和项目名都合适。我给它定的核心循环非常简单玩家出生在地图中央周围散落着浆果和木柴地图边缘有一个洞穴入口右上角有一个不断减少的黄昏倒计时。玩家要做的只有三件事采集足够的资源、躲避游荡的恐龙、在倒计时归零前回到洞穴。听起来简单但真正把这三件事融合进一个 320x192 像素的小地图里就有了完整的游戏张力。采集资源给你目标感恐龙巡逻给你压力倒计时把压力变成持续的动作指令。玩家从头到尾没有一句台词也没有一段新手引导所有的理解都来自于画面和时间压迫感。这就是我想让 caveman 达到的效果一个词就能概括玩法一张截图就能看出在玩什么。1.2 砍掉大而全只留三个玩法模块做小游戏最容易犯的错就是开局想太多要背包系统、要技能树、要昼夜循环、要存档。我一开始列了十几个功能最后几乎全砍了只留三个模块移动与碰撞玩家通过 WASD 或方向键在二维地图上移动被地图上的树、石头挡住。采集与目标碰到浆果和木柴自动拾取背包只显示数量和图标不做物品栏 UI。天黑与胜负倒计时结束前回到洞穴且资源足够即胜利倒计时结束还在野外即失败。这个 MVP 版本我给自己定的标准是“能在一个晚上跑起来”。任何功能如果在这个范围之外全部记进文档的待办清单里先不做。事实证明这个边界划定很值整个游戏从空目录到能通关只花了一个晚上加一个上午。等跑通了再回头加东西心态完全不同因为你已经有一个可以玩、可以展示的版本了。1.3 技术选型对比为什么是原生 Canvas 而不是 Phaser选技术栈的时候我认真做过对比。很多人一上来就用 Phaser我也用过它确实省事物理、动画、场景管理全是现成的但用在这种迷你项目上会有一个问题你会的东西越多就越不关心底层发生了什么。caveman 这种项目体量小到根本不需要引擎一个 canvas 标签加几百行 JavaScript 就够。方案优点缺点适合场景原生 Canvas JS零依赖、逻辑透明、加载快所有系统都要自己写学习底层、小体量、可控范围Phaser动画/物理/场景齐全包体积大、抽象层厚有复杂图集和场景切换的 2D 游戏Unity 2D工具链强大、跨平台上手成本高、项目显重需要编辑器长线迭代的作品我最后选了原生 Canvas。除了上面说的学习价值之外还有一个很现实的原因caveman 是没有美术资源的项目所有像素图和音效都用代码生成用 Phaser 的话等于开着卡车去便利店买瓶水工具带多了反而是负担。2. 搭建游戏骨架状态机、主循环和输入处理2.1 游戏状态机MENU / PLAYING / WIN / GAMEOVER游戏哪怕再小也一定要有状态机。我见过很多同学直接在全局变量里挂一堆布尔标记来控制界面isPlaying、isWin、isGameOver 互相组合写到最后自己都分不清哪个优先。caveman 里我只用一个 state 字符串四个状态MENU、PLAYING、WIN、GAMEOVER。let state MENU; function switchState(next) { if (next PLAYING) { resetGame(); } state next; }switchState 是所有状态迁移的唯一入口。好处有两个第一可以在这里统一做状态切换时的初始化比如切到 PLAYING 时调用 resetGame 重置位置、时间、背包第二调试的时候只需要打印 state就知道游戏卡在哪个环节。后来我还加了每帧在控制台输出当前状态的调试开关这个习惯救了我好几次尤其当触控输入和键盘输入混在一起的时候你能迅速确认玩家到底处于哪个阶段。2.2 主循环里 deltaTime 为什么重要游戏主循环就是用 requestAnimationFrame 包住 update逻辑更新和 render绘制两步。刚写原型时我把移动逻辑直接写成了每帧固定位移量在自己电脑上 60 帧跑着没问题可一旦帧率波动角色速度就会忽快忽慢。这就是必须引入 deltaTime 的原因也就是上一帧到这个帧的真实耗时所有移动量都基于它来计算。let lastTime 0; function loop(t) { let dt (t - lastTime) / 1000; lastTime t; dt Math.min(dt, 0.05); // 防止切后台后 dt 过大 update(dt); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);dt 的单位是秒玩家的移动速度我设成 90 像素/秒那么每帧的移动量就是 90 * dt。这里有个非常关键的坑浏览器标签页切走再切回来时dt 可能突然变成好几秒如果不做 clamp角色会在恢复的瞬间瞬移一大段距离甚至穿墙。所以Math.min(dt, 0.05)这行不是可有可无它是移动稳定性的底线。2.3 键盘输入同时按住两键不打架输入处理我用了一个简单的键位状态记录keydown 时把对应的 code 标记为 truekeyup 时标记为 false。update 里直接查这个对象而不是监听键事件来触发移动。这样连续按键没有延迟问题同时按两个方向键系统也不会漂移。let keys {}; window.addEventListener(keydown, (e) { keys[e.code] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space].includes(e.code)) { e.preventDefault(); } }); window.addEventListener(keyup, (e) { keys[e.code] false; });玩家移动逻辑里我允许同时响应 W/S 和上下左右共四组方向键键位用 e.code 而不是 e.key因为 e.key 在切换输入法时可能出现异常code 则稳定可靠。移动时还要注意对角线速度修正如果 dx 和 dy 同时为 1直接归一化后速度会变成约 1.414 倍必须乘以 0.7071。let dx 0, dy 0; if (keys[KeyW] || keys[ArrowUp]) dy - 1; if (keys[KeyS] || keys[ArrowDown]) dy 1; if (keys[KeyA] || keys[ArrowLeft]) dx - 1; if (keys[KeyD] || keys[ArrowRight]) dx 1; if (dx ! 0 dy ! 0) { dx * 0.7071; dy * 0.7071; } player.x dx * player.speed * dt; player.y dy * player.speed * dt; player.x clamp(player.x, 0, GAME_W - player.w); player.y clamp(player.y, 0, GAME_H - player.h);3. 没有美术就手写像素精灵3.1 先做好画布像素风的关键是 imageSmoothingEnabledfalsecaveman 的像素风不是靠零碎素材拼出来的而是用一套逻辑分辨率加 Canvas 缩放实现的。我把游戏内分辨率定为 320x192就是当年掌机的经典分辨率大小。Canvas 宽高设置成这个数值CSS 里把它放大到 960x576也就是 3 倍整数放大每个像素都变成一个整齐的方块不会出现半像素模糊。const canvas document.getElementById(game); const ctx canvas.getContext(2d); const GAME_W 320; const GAME_H 192; canvas.width GAME_W; canvas.height GAME_H;关键配置是关闭图像平滑。否则从离屏 canvas 绘制素材时浏览器会自动给你做插值像素边缘会糊成一团整个复古感就没了。canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 100%; max-width: 960px; aspect-ratio: 320 / 192; display: block; margin: 0 auto; }3.2 用二维数组设计 Caveman 精灵没有美术素材我就用二维数组直接定义精灵。每个字符代表一种颜色做一个映射表比如 B 代表棕色身体S 代表肤色_ 代表透明。这样设计精灵就像在网格纸上画图一样直观。const PALETTE { X: #2b1d0e, // 深棕 B: #8a5a2b, // 身体棕 S: #e8b98a, // 肤色 W: #f7f3e8, // 眼白 K: #222222, // 瞳孔 }; const PLAYER_FRAMES [ [ ____XX______, ___XBBX_____, ___BBSSX____, __BBSSBB____, ___BBBBX____, ___XBBBB____, ___XBBXX____, ___SSSSS____, XX_XSSSX_X__, XSSXSSSXSSX_, XSS_SSS_SSX_, _X___S___X__, ], [ ____XX______, ___XBBX_____, ___BBSSX____, __BBSSBB____, ___BBBBX____, ___XBBBB____, ___XBBXX____, ___SSSSS____, _XX_SSS_XX__, XSSXSSSXSSX_, _SS_SSS_SSX_, __X__S___X__, ], ];只要改数组里的字符就能改角色的发型、颜色、衣服比用绘图工具还快。这种“代码就是美术资源”的方式特别适合独立小项目因为你不需要 Photoshop不需要字节跳动素材库甚至不需要一个在线像素编辑器。3.3 把精灵画进离屏 canvas性能直接从 O(N) 降到 O(1)最朴素的做法是每帧遍历二维数组用 fillRect 逐像素画。但一帧要画几百个像素而且每一帧都重复再加上地图 tile 也这么画Canvas 绘制指令数会爆炸。我一开始也是这么写的运行后 FPS 掉到 40 左右明显有卡顿感。解决方法是把精灵图预先渲染到一个离屏 canvas 上游戏循环里只用 drawImage 把整个画布贴过去。这样每个精灵每帧只有一次 drawImage 调用绘制指令从几百个降到两三个。function makeSprite(rows, palette) { const w rows[0].length; const h rows.length; const off document.createElement(canvas); off.width w; off.height h; const octx off.getContext(2d); for (let y 0; y h; y) { for (let x 0; x w; x) { const color palette[rows[y][x]]; if (!color) continue; octx.fillStyle color; octx.fillRect(x, y, 1, 1); } } return off; }整个项目里玩家精灵、恐龙精灵、浆果、木柴、洞穴门口全部用这种方式生成。运行时不再关心每个像素颜色是什么直接当成一张小图来用性能和代码可读性都提升了一个档次。3.4 两帧动画让角色“活”过来的做法静态精灵也能玩但角色移动时没有走路动画会显得很呆。caveman 里我做了最简单的两帧动画一个帧是正常站姿另一个帧是稍微错开腿的姿势交替播放。player.frameTimer dt; if (player.frameTimer 0.15) { player.frameTimer - 0.15; player.frameIndex (player.frameIndex 1) % PLAYER_FRAMES.length; } const frame PLAYER_FRAMES[player.frameIndex]; ctx.drawImage(player.spriteCache[frame], player.x, player.y);但这里有个小细节我后面才意识到玩家静止站立时不应该一直切换动画否则看起来像在原地踏步。于是我又加了个条件只有当移动距离大于 0 时才更新 frameTimer停下来就固定在第 0 帧。你别说就这么两帧动画做不做这个判断手感差别非常明显。4. 碰撞检测与天黑机制这个项目最容易翻车的地方4.1 AABB 碰撞判定先看矩形再看像素像素级碰撞最准但在这种小游戏里完全没必要我全部用 AABB也就是轴对齐的矩形包围盒来判定。两个物体只要在 x 轴和 y 轴上的投影区间都有重叠就算碰撞。判断条件就四行熟悉之后你会发现它就是最基本的区间比较。function hit(a, b) { return a.x b.x b.w a.x a.w b.x a.y b.y b.h a.y a.h b.y; }唯一要注意的是不要把整个精灵的宽高当判定框。精灵图里角色脚底往往有空隙或装饰我把玩家判定框向左上角各缩了 2 像素宽高各减 4这样角色走到物品旁边时视觉上贴近了才触发手感不会觉得“没碰到就被吸收”或者“碰到了却判定失败”。player.hitbox function () { return { x: this.x 2, y: this.y 2, w: this.w - 4, h: this.h - 4, }; };4.2 自动拾取比按键交互更符合“收集”手感一开始我设计成玩家靠近浆果后按空格键拾取结果测试时总感觉操作链太长你要先对准再按键再把手指移回方向键。后来我改成碰到就自动拾取整个游戏节奏立刻变得流畅。采集类小游戏的乐趣在于“路过即获取”的正反馈而不是让你下意识狂按交互键。实现就是遍历物品数组一旦 hitbox 相交且 item.taken 为 false就标记拾取同时把对应数据加到玩家背包。items.forEach(item { if (item.taken) return; if (!hit(player.hitbox(), item.hitbox())) return; item.taken true; if (item.type berry) { player.berries 1; } else if (item.type wood) { player.wood 1; } sfxPick(); });踩坑记录一定别忘了加 item.taken 标记。我第一版把捡到的物品从数组里删除以为没事结果游戏每帧都在创建新数组GC 压力变大而且偶尔出现拾取瞬间卡一下。改成标记法之后对象生命周期稳定多了。4.3 天黑的视觉反馈不只是倒计时倒计时数字能提示玩家还剩多少时间但不会产生紧迫感。真正的压力来自画面本身的变化。我实现了一个黑暗度叠加层根据 timeLeft 和 maxTime 的比例计算出当前天色应该暗到什么程度然后覆盖一层半透明的深蓝色矩形。function drawDarkness() { const t clamp(timeLeft / maxTime, 0, 1); // 1 白天0 全黑 const alpha (1 - t) * 0.68; ctx.fillStyle rgba(12, 18, 80, ${alpha}); ctx.fillRect(0, 0, GAME_W, GAME_H); }当 alpha 接近 0.68画面已经明显“入夜”角色轮廓依然可见但氛围上能感受到再不回洞穴就危险了。很多小游戏喜欢在最后几秒闪红屏我试了之后觉得太出戏还是渐变夜色更贴合 caveman 的调性。最后 5 秒我额外加入一个心跳音效作为倒计时提醒反馈比视觉直接得多。4.4 恐龙 AI 的简单巡逻和危险度递增恐龙的 AI 我没有做寻路算法那对这个小项目来说是杀鸡用牛刀。我用的是伪随机方向加边界反弹每只恐龙有一个 currentDir每隔两秒随机换一个方向走到地图边缘就自动修正为反向。这样效果上很像在巡逻消耗极低。天黑机制让它变得危险恐龙速度随天黑程度提高。白天速度只有 40 像素/秒入夜后最高能到 100 像素/秒。玩家和恐龙碰撞后我的处理是把你送回地图中心的初始位置同时丢弃一个浆果作为惩罚。这样做比直接游戏结束更温和给了玩家挣扎的机会又不会让惩罚形同虚设。dinosaurs.forEach(dino { dino.dirChangeTimer - dt; if (dino.dirChangeTimer 0) { dino.dirChangeTimer 1.5 Math.random() * 1.5; dino.vx (Math.random() - 0.5) * 2; dino.vy (Math.random() - 0.5) * 2; } const speed 40 (1 - timeLeft / maxTime) * 60; const len Math.hypot(dino.vx, dino.vy); dino.x (dino.vx / len) * speed * dt; dino.y (dino.vy / len) * speed * dt; if (dino.x 0 || dino.x GAME_W - dino.w) dino.vx * -1; if (dino.y 0 || dino.y GAME_H - dino.h) dino.vy * -1; if (hit(player.hitbox(), dino.hitbox())) { player.x START_X; player.y START_Y; player.berries Math.max(0, player.berries - 1); sfxHit(); } });5. 没素材也能有音效用 Web Audio 现合成5.1 为什么不用音频文件一个几十 KB 的项目不该放几 MB 音乐caveman 的目标是零依赖、零外部资源。音频如果用 mp3 文件哪怕是一个很短的音效也要几十 KB一个完整的音乐包轻松破 MB。我最后决定用 Web Audio API 直接合成音效这样项目所有资源加起来依然只有几十 KB托管在任何静态页面都能秒开。Web Audio 听着高大上其实核心逻辑就是一秒内创建一个震荡器 oscillator设置好频率变化和音量包络播完自动停掉。我封装了一个 blip 函数五六个音效全部由它派生。5.2 三个实用音效合成函数拾取、失败、心跳项目里我实际用到了三个音效拾取物品的上扬短音、被恐龙碰到后的低沉撞击音、最后 5 秒的心跳报警音。都用同一个函数完成。const AC new (window.AudioContext || window.webkitAudioContext)(); function blip(freq, endFreq, duration, type square, volume 0.2) { const osc AC.createOscillator(); const gain AC.createGain(); osc.type type; osc.frequency.setValueAtTime(freq, AC.currentTime); osc.frequency.exponentialRampToValueAtTime(endFreq, AC.currentTime duration); gain.gain.setValueAtTime(volume, AC.currentTime); gain.gain.exponentialRampToValueAtTime(0.0001, AC.currentTime duration); osc.connect(gain); gain.connect(AC.destination); osc.start(); osc.stop(AC.currentTime duration); } function sfxPick() { blip(520, 880, 0.1, square, 0.18); } function sfxHit() { blip(220, 70, 0.25, sawtooth, 0.25); } function sfxHeartbeat() { blip(90, 45, 0.08, square, 0.3); }这里必须提醒一个兼容性问题AudioContext 不能在用户没有交互的情况下启动。如果页面加载完就直接创建播放Chrome 会把它挂起直到用户点击页面。我的处理是首次点击画布时调用AC.resume()这样玩家一开始游戏音频上下文就已经处于激活状态。5.3 移动端适配虚拟摇杆和画布缩放做完桌面端后我顺手适配了移动端。因为游戏只用方向操作我在页面底部画了一个虚拟摇杆区域。指针按下时记录起点移动时计算相对位移映射成 dx 和 dy超过摇杆半径就做归一化。触摸事件里最大的坑是坐标转换触摸点在屏幕上的坐标跟 Canvas 内的逻辑坐标之间有缩放比例直接用会偏得离谱。const rect canvas.getBoundingClientRect(); const scaleX GAME_W / rect.width; const scaleY GAME_H / rect.height; const touchX (touch.clientX - rect.left) * scaleX; const touchY (touch.clientY - rect.top) * scaleY;在做触控时我还顺带测了个细节canvas 的 CSS 尺寸变化会导致 getBoundingClientRect 变化所以坐标转换必须在 touch 事件里现算不能在页面初始化时缓存一次就完事。尤其手机横竖屏切换、浏览器地址栏收起这类情况canvas 宽度会变缓存值早就不准了。6. 跑起来才发现的问题与优化6.1 切后台再回来角色“瞬移”的坑这个坑我在 2.2 节已经埋了伏笔实际项目里它真的发生了。玩家切到别的标签页再切回来dt 会是一个巨大值角色直接穿墙飞出地图。加了 clamp 之后问题解决了但这里还有更隐蔽的现象如果浏览器被冻结超过几秒requestAnimationFrame 的 timestamp 依然会从大间隔开始算哪怕 Math.min 也只是限制了当帧移动量你实际还是丢失了一段游戏时间。我的处理是在 update 开头检测 dt 超过 0.1 秒时就进入一次“暂停状态”把倒计时也暂停防止玩家因为切后台损失整个游戏进度。if (dt 0.1) { // 说明刚从后台回来 timeLeft - dt * 0.5; return; }这个策略不是标准的但你做这种休闲小游戏时体验比“绝对正确的时间流动”更重要玩家切回来发现时间没怎么少心里会舒服很多。6.2 高 DPI 屏幕上的模糊像素macOS 的 Retina 屏、很多手机都是高分屏。如果 Canvas 逻辑分辨率是 320x192CSS 又放大到 960那在 Retina 上物理像素其实是 2880 宽浏览器会做插值你精心设计的像素块边缘就被糊掉了。我推荐一个简单组合CSS 里加 image-rendering: pixelated如果还是觉得糊可以把 Canvas 内部按 DPR 提分辨率。我最后选的是固定 3 倍放大不做 DPR 适配因为像素风游戏本来就不是追求高清而是追求锐利。凡是开了 DPR 适配的项目都要同时处理触摸坐标和屏幕坐标的换算复杂度会涨一截caveman 这种体量不值得。6.3 对象池和重绘区域小项目也别太放飞播放音效、反复创建定时器、每帧 new 对象这些在桌面浏览器上可能感觉不到问题但在低端手机上很容易卡。caveman 规模小我没写完整对象池但做了两件顺手的事物品和恐龙全部在 resetGame 时创建一次之后只修改状态不创建新对象HUD 里的字符串数字拼接到 canvas 上没有用 drawText 每帧重绘而是只在数值变化时更新缓存画布。坦白讲这两条对这个项目来说不是必须的但你把它们做进去之后整包游戏在低端安卓机的触摸操作下也能稳定 60 帧。小项目养成好习惯后面做大项目就不用临时补课了。6.4 还能怎么扩展地形、烹饪、多人模式caveman 作为一个基础版本玩法已经闭环但我也记了不少扩展路线。最直接的是加入水域和独木桥地形增加路线规划或者给浆果加熟成状态烤过的果实才能恢复体力这就催生了篝火和使用逻辑再进一步可以做双人模式一个负责引开恐龙一个负责采集画面分屏显示游戏复杂度立刻上升一个量级。看到这些方向后你会发现当初砍功能不是失去而是把每个值得做的点子都留到了它真正配得上的版本里。做完这个项目以后我对“小游戏”这三个字的理解变了。以前总觉得游戏至少要有大场景、复杂系统、成体系的美术caveman 用 320x192 的像素画布告诉我只要核心循环成立玩家三分钟内就能投入进去。整个项目从零到发布所有代码和资源加起来还不到 100KB托管到任何一个静态平台都能跑这本身就是一种很踏实的成就感。最后分享一个我实测下来的建议别急着去啃完整的游戏引擎文档先用一个周末像这样用 Canvas 手写一个包含完整循环的小游戏你会发现后续学任何引擎都快得多很多概念你已经亲手撞过一遍了。