☰
用原生Canvas手写像素小游戏:从状态机到碰撞的完整实践
2026/10/8 11:24:08 网站建设 项目流程

“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 标记为 true,keyup 时标记为 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 先做好画布,像素风的关键是 imageSmoothingEnabled=false

caveman 的像素风不是靠零碎素材拼出来的,而是用一套逻辑分辨率加 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 逻辑分辨率是 320x192,CSS 又放大到 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 手写一个包含完整循环的小游戏,你会发现后续学任何引擎都快得多,很多概念你已经亲手撞过一遍了。

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

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

立即咨询