用原生 JavaScript 和 Canvas 从零构建街机手感平台跳跃游戏
做了这么多年 Web 开发我一直有个执念用浏览器技术做一款能让自己玩得下去的平台跳跃游戏。这个项目我起名叫 Caveman主角就是一个拿骨头棒子砸天砸地的原始人。它不是什么大制作技术上也没有黑魔法但把一个原始人从跑、跳到扔骨头、打恐龙、过机关这整套流程在 Canvas 上跑通并且手感能和自己小时候街机厅里玩过的那些经典作品对得上这本身就是一件很有意思的事。最近原始人这个词在中文互联网上也挺热大家喜欢用它形容那种不依赖花哨工具、直来直去的状态我觉得这个项目的气质恰好也是这样的不用引擎不用框架就浏览器里一支 Canvas把最底层的游戏逻辑一条条写明白。这篇文章想把 Caveman 从立项到写完的完整过程摊开讲包括架构、物理参数、敌人 AI、关卡生成、美术音效还有一堆踩坑记录。适合想做网页小游戏、想搞明白 2D 平台跳跃到底是怎么一回事的开发者。无论你是第一次写游戏循环还是已经写过几个 Demo 想看看别人怎么处理手感应该都能找到点有用的东西。1. 为什么做Caveman一个怀旧街机玩家的自问自答1.1 从街机厅到浏览器这个项目的灵感来源先说清楚这个项目的来路。小时候在街机厅玩过一款叫《Caveman Ninja》的游戏国内也有叫《原始人》的台湾那边翻译成《恐龙快打二代》的也有。主角是个浑身肌肉的原始人拿石头、骨头当武器一路打恐龙、打野猪、打各种奇怪生物。那会儿觉得这游戏特别爽跳跃手感扎实武器扔出去还能在空中旋转敌人被打中之后会夸张地飞出去。后来我在模拟器上又翻出来玩过几次发现二十多年过去它的关卡节奏和打击反馈依然比现在很多独立游戏做得讲究。但街机游戏有个问题它的代码、资源都是封闭的你想研究它的手感是怎么调出来的只能靠玩着猜。Caveman 这个项目就是因为这个好奇起来的——我想不看任何现成游戏源码纯粹靠自己推导把一个感觉类似的原始人平台跳跃游戏在网页上复刻出来。我不追求还原《Caveman Ninja》的每一帧而是提取它的核心体验跑动利落、跳跃有弹性、投掷有弧线、打击有反馈。这个目标足够清晰也足够有挑战性。之所以选原始人题材还有一个很实际的理由美术成本可以压得非常低。现代游戏做一个角色动辄几百帧动画但原始人只需要一块兽皮、一根骨头、一个圆脑袋就能被认出来。场景更是简单——石头、泥土、树、山洞全是低多边形和像素画最容易表现的东西。对一个单人项目来说题材首先得画得动然后再谈画得好。原始人题材天然适合小体量开发这也是我敢用几个晚上就把框架搭完的原因。1.2 项目边界做什么、不做什么、怎样算完成单人做游戏最容易死在哪不是技术是范围失控。我见过太多朋友第一周热血沸腾第二个月项目就再也打不开因为想塞的东西太多了。所以 Caveman 立项的时候我给自己写了一张范围清单直接贴在编辑器上面核心玩法跑、跳、扔骨头三种输入就能构成完整的战斗循环敌人数量两种普通敌人加一个 Boss不做第五十种怪关卡数量三个手绘关键关卡加一个程序生成的无限模式目标平台桌面浏览器键盘 手机浏览器触控按钮明确不做不做联网排行不做云存档不做过场动画不做技能树怎样算完成我也写得很具体能在一关内完整经历跑图—遭遇敌人—投掷击杀—跳跃过沟—遭遇 Boss—胜利结算这个闭环游戏循环超过 20 分钟不崩手机和电脑上都跑得顺就算完成。范围清单这个东西特别重要尤其是做游戏因为好玩是无限可分的目标你永远可以再加一个敌人、再调一版手感、再画一套材质。没有边界的话项目永远在 Beta 状态永远不会发布。这套先定义完成的思路其实是整个项目里我最想强调的一件事。后面所有技术细节固然重要但如果不是一开始就砍掉联网、存档这些需求我根本不可能在有限精力里把核心手感打磨到这个程度。2. 游戏循环与基础架构先把画面能动这件事做扎实2.1 固定时间步长与渲染分离Caveman 没有用 Phaser也没有用 Godot 导出 HTML5就是原生 JavaScript 加 Canvas 2D。原因很直接我想把游戏循环、碰撞、状态管理这些底层逻辑亲手写一遍而不是被框架隐藏起来。原生实现除了学习价值还有一个实际好处——最终的页面就是一个静态 HTML 文件加几个 JS部署到任何静态托管都很方便。游戏循环是整个项目的地基。这里有两条路线每帧渲染一次、逻辑也每帧算一次也就是可变时间步长另一种是逻辑用固定时间步长计算渲染时再把最新状态画出来。Caveman 用的是后者。固定时间步长的核心代码很简短let lastTime 0; let accumulator 0; const FIXED_DT 1 / 60; // 逻辑按 60fps 固定步长推进 function frame(timestamp) { const delta Math.min((timestamp - lastTime) / 1000, 0.05); // 钳制最大帧间隔 lastTime timestamp; accumulator delta; while (accumulator FIXED_DT) { update(FIXED_DT); // 游戏逻辑更新传入固定步长 accumulator - FIXED_DT; } render(); // 渲染采用当前状态与逻辑步长解耦 requestAnimationFrame(frame); } requestAnimationFrame(frame);为什么要这么写因为平台跳跃游戏对物理确定性要求很高。如果逻辑和渲染同步60Hz 屏幕和 144Hz 屏幕上角色的跳跃高度、移动速度会不一样甚至同一台电脑上卡顿一下人物就会突然多飞一截。固定步长保证无论屏幕刷新率是多少、无论帧率怎么波动每次 update 得到的 dt 都是同样的 1/60 秒。渲染只管画不影响模拟。注意那个Math.min(delta, 0.05)的钳制这是血泪教训。用户切换浏览器标签页、电脑休眠唤醒之后浏览器会补发一个巨大的时间戳差值。如果不钳制游戏循环会试图一次性把几十帧的物理全部计算完角色直接穿过整个关卡。钳到 50 毫秒后最多就是画面卡一下但物理不会爆炸。这个细节后面还会在踩坑部分专门讲。2.2 场景管理与资源加载游戏再小也分场景Caveman 的角色约等于标题界面、游戏关卡、Boss 战、结算界面。我写了一个很轻量的 SceneManager本质是一个栈结构const scenes { title: new TitleScene(), game: new GameScene(), boss: new BossScene(), gameover: new GameOverScene(), }; let currentScene scenes.title; function switchScene(name) { currentScene.onExit(); currentScene scenes[name]; currentScene.onEnter(); }每个 Scene 必须实现onEnter、update(dt)、render(ctx)、onExit四个方法场景内部再管理自己的实体列表。这个模式看着简单但对单人项目极其够用。它把逻辑按场景隔离游戏关卡的实体不会在结算界面里继续更新Boss 战的血条 UI 也不会跑到普通关卡里渲染。不要一上来就引状态机框架先用手写场景栈跑通等出现多级嵌套需求了再升级不迟。资源加载是另一个容易翻车的地方。Caveman 里的美术资源不多但也要保证音效、图像都就绪才开始游戏。我写了一个简单的loadAssets()用 Promise.all 把所有图片和音频文件加载完再进入标题场景async function loadAssets() { const imgPromises ASSET_LIST.map(({ name, url }) loadImage(url).then((img) (assets[name] img)) ); const audioPromises AUDIO_LIST.map(({ name, url }) loadAudio(url).then((buf) (audio[name] buf)) ); await Promise.all([...imgPromises, ...audioPromises]); }实际项目里还可以加一个 loading 进度条但核心是决不允许在资源未加载完时启动 update 循环否则 Canvas 上会疯狂报Cannot read properties of undefined而且这种错误特别难排查因为你看到的是白屏加报错但根源是资源加载顺序问题。2.3 Canvas 的像素设置两行代码影响全局体验Canvas 2D 本身画矩形、画图片非常快但要做像素风格游戏有个设置必须记得关掉否则画面会糊成一团。那就是imageSmoothingEnabled。Canvas 默认对缩放后的图片做双线性插值16x16 的小图放大到 3 倍、4 倍之后会产生脏兮兮的模糊边缘。像素画要的就是那种锯齿分明的硬边效果所以启动时一定要加这两行ctx.imageSmoothingEnabled false; ctx.imageSmoothingQuality low;同时要处理高分屏的适配。Retina 屏的设备像素比是 2 或 3如果 Canvas 的像素尺寸小于 CSS 尺寸画出来就发虚。我的做法是逻辑分辨率固定为 320x180这是很多像素游戏的经典尺寸然后根据window.devicePixelRatio缩放 Canvas 的实际像素尺寸最后把逻辑坐标映射到物理像素const DPR window.devicePixelRatio || 1; canvas.width 320 * DPR; canvas.height 180 * DPR; ctx.scale(DPR, DPR);这样一来游戏里所有坐标都是按 320x180 的逻辑分辨率来写不需要关心设备差异。相机偏移也是一个简单的ctx.translate(-camera.x, -camera.y)把世界坐标平滑地拉到镜头中心。这套基础架构搭完之后后面所有玩法开发都是在稳定的地基上干活至少没再因为画面为什么不动为什么这么糊这类问题浪费过时间。3. 原始人操控细节跑、跳、扔骨头的物理模型与手感调校3.1 移动物理参数加速度比匀速更接近人平台跳跃游戏最核心的体验就是操控感。Caveman 的移动模型我没有用按下方向键速度立刻变成最大值这种简单方式而是采用了加速度模型。原因在于直接设定速度的角色看起来像在冰面上滑行的纸片缺少重量感真实的人从静止到全速奔跑需要一个蓄力过程停下来也需要一点缓冲。这个蓄力和缓冲就是所谓的手感的重要来源。Caveman 的角色参数如下这个表我调了很久是全文里最有价值的部分参数数值作用说明地面加速度0.35每帧从静止到最大速度约 19 帧空中加速度0.18每帧空中操控性明显弱于地面增加重量感地面摩擦力0.30每帧松开方向键后快速停住最大水平速度220 px/s单位是逻辑像素每秒重力加速度620 px/s²下落快跳跃后段下坠明显最大下落速度420 px/s防止自由落体速度失控跳跃初速度380 px/s配合重力算出最大跳跃高度约 116px对应代码大概是这样的function updatePlayer(player, input, dt) { const isOnGround player.onGround; let accel isOnGround ? PLAYER.ACCEL_GROUND : PLAYER.ACCEL_AIR; if (input.left) { player.vx - accel * dt * 60; } else if (input.right) { player.vx accel * dt * 60; } else if (isOnGround) { const f player.vx 0 ? -PLAYER.FRICTION : PLAYER.FRICTION; player.vx f * dt * 60; } player.vx clamp(player.vx, -PLAYER.MAX_SPEED, PLAYER.MAX_SPEED); player.vy Math.min(player.vy PLAYER.GRAVITY * dt, PLAYER.MAX_FALL); player.x player.vx * dt; player.y player.vy * dt; }注意一个细节空中加速度只有地面的一半。这是刻意的。如果空中和地面操控性一样角色跳起来后像无人机一样灵活会有严重的飘感削弱空中操控后玩家必须在地面就把路线规划好跳跃本身变成一次有风险的投入这种紧张感正是平台游戏乐趣的来源。很多初学者调手感只调跳跃高度和速度忽略了空中加速度结果做出来的游戏怎么玩怎么不对。3.2 跳跃的宽容机制土狼时间与跳跃缓冲如果说物理参数决定了手感的下限那么两个小机制直接决定了手感的上限一个是土狼时间Coyote Time一个是跳跃缓冲Jump Buffer。这两个词看起来玄乎其实道理很简单。土狼时间是允许玩家在离开平台后很短的一段时间内仍然能跳起来。想象玩家跑过一个平台边缘他可能晚按了跳跃键 0.1 秒严格来说那时他已经不在平台上了但如果系统判定直接取消跳跃输入玩家会感觉我明明按了跳跃为什么没反应非常挫败。土狼时间的做法是记录玩家离开平台的时间如果这个时间小于 0.08 秒仍然允许跳跃。0.08 秒很短玩家感知不到这种作弊但能大幅减少边缘失误。跳跃缓冲则相反它允许玩家在落地前就按下跳跃键系统把这次输入暂存 0.12 秒等角色落地的一瞬间自动触发跳跃。这样玩家不需要精确地在落地那一帧按键稍微提前一点按也能跳起来。两个机制的实现加起来不到二十行// 玩家状态里维护两个计数 player.coyoteTimer player.onGround ? COYOTE_TIME : player.coyoteTimer - dt; player.jumpBufferTimer input.jumpPressed ? JUMP_BUFFER_TIME : player.jumpBufferTimer - dt; // 在 update 里判断 if (player.jumpBufferTimer 0 player.coyoteTimer 0) { player.vy -PLAYER.JUMP_VELOCITY; player.jumpBufferTimer 0; player.coyoteTimer 0; }我自己的体会是加了这两个机制之后Caveman 的跳跃容错率明显上了一个台阶不再是高手才能完整体验的动作游戏。另外我还做了可变跳跃高度——玩家跳起来后如果在上升段松开跳跃键垂直速度会乘以一个 0.55 的系数角色就会明显矮一截。这给玩家提供了轻点一下小跳、长按大跳的节奏选择是平台跳跃里几乎必备的手感元素。3.3 投掷武器的弧形轨迹扔骨头是 Caveman 的核心攻击方式也是主角最有识别度的动作。骨头的行为模型是从玩家面前生成带有水平初速度和向上的垂直初速度然后全程受重力影响形成一个抛物线。同时骨头自身在旋转旋转速度大概每秒 5 圈。这样玩家在视觉上会强烈感觉到这是一个被甩出去的钝器而不是一个平移的矩形子弹。投射物的更新代码非常简单class Bone { constructor(x, y, dir) { this.x x; this.y y; this.vx dir * 380; // 水平初速度朝面向方向 this.vy -180; // 向上初速度 this.rotation 0; this.rotationSpeed dir * 10; this.life 1.2; // 飞行 1.2 秒后消失 } update(dt) { this.vy 520 * dt; // 骨头有自己的重力 this.x this.vx * dt; this.y this.vy * dt; this.rotation this.rotationSpeed * dt; this.life - dt; } }我调参数时重点观察两个东西一是骨头飞出去是否有明显的先升后降弧线二是玩家能不能凭直觉预判击中距离。太直的弹道像子弹失去了钝器的感觉太弯的弹道又难以瞄准。最终选定的水平 380、垂直 -180、重力 520 这组参数在 320x180 的逻辑分辨率下大概能飞出 250 到 300 像素的射程刚好覆盖大半个屏幕。这让玩家在屏幕边缘就能击中中间的敌人同时又不会变成全屏无脑发射。另外我给骨头加了一个条件击中地面或墙壁就消失并播放一个啪的撞击小特效。这个设计的坏处是打空了会有明显惩罚好处是战斗更有节奏感玩家会愿意贴近敌人再出手。取舍之下我保留了它。实际上这种投射物碰墙消失的设定在像素游戏里非常普遍因为它天然地划定了战斗距离避免玩家远程风筝一切敌人。4. 敌人与碰撞让猎物从木头桩变成活物4.1 敌人状态机巡逻、警觉、攻击一个平台跳跃游戏光有角色操纵是不够的还得有敌人让玩家产生交互。Caveman 里我实现了两种普通敌人一种是靠近就追的剑齿虎一种是固定位置、周期性吐石头的秃鹫或者叫始祖鸟。两者的核心都是一个极简状态机。我在这里刻意没有引入复杂的行为树每个敌人的状态机就四到五个状态待机、巡逻、追击、攻击、受击。以剑齿虎为例它的行为规则是当前状态进入条件行为转移到待机初始状态站立不动偶尔张望玩家进入感知范围 → 追击巡逻待机超过 2 秒在限定平台范围来回走玩家进入 180px 范围 → 追击追击感知到玩家朝玩家方向加速跑超出 320px 范围 → 待机攻击与玩家距离小于 24px咬击造成伤害攻击结束 → 追击受击被骨头击中后退 6 帧播放受伤动画受击结束 → 追击用 switch 实现状态切换时最需要注意的是状态进入时的初始化。比如切换到追击状态要把当前的翻转方向立刻指向玩家切换到受击状态要清空敌人的水平速度否则会出现一边被击退一边还在跑的精分表现。我因为这个吃过亏后面会细讲。下面是一段简化版的状态更新逻辑update(dt) { switch (this.state) { case patrol: this.vx this.dir * 50; break; case chase: this.dir this.player.x this.x ? 1 : -1; this.vx this.dir * 120; break; case attack: this.vx 0; // 攻击时定住避免边动边攻击 break; case hit: this.vx this.knockDir * 80; this.vy -120; // 受击小跳 break; } }这个状态机完成后剑齿虎给人的观感立刻就不一样了它不再是站在原地傻乎乎等玩家打的靶子而是会巡逻、会警觉、会追击、会被打飞。一个简单的状态机就能让敌人活起来这算是 2D 游戏性价比最高的投资之一。4.2 AABB 碰撞解析顺序决定的不是能不能而是舒服不舒服Caveman 里所有实体碰撞都用 AABB轴对齐包围盒来处理。AABB 的思路就是每个物体用一个矩形框代表判断两个矩形是否相交就是四行代码的事function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax bx bw ax aw bx ay by bh ay ah by; }但检测相交只是第一步更关键的是相交之后怎么解析。平台跳跃游戏里角色踩到平台上要停住撞到墙壁要滑下来头撞到天花板上要落回去。如果解析顺序不对最常见的问题就是卡墙角抖动——角色被平台边缘反复弹来弹去像得了帕金森。我的做法是先移动 X 轴再单独解析 Y 轴然后移动 Y 轴再解析 X 轴也就是把水平和垂直方向分开处理// X 轴移动与解析 player.x player.vx * dt; for (const tile of solidTiles) { if (aabb(player, tile)) { if (player.vx 0) player.x tile.x - player.width; else if (player.vx 0) player.x tile.x tile.width; player.vx 0; } } // Y 轴移动与解析 player.y player.vy * dt; player.onGround false; for (const tile of solidTiles) { if (aabb(player, tile)) { if (player.vy 0) { player.y tile.y - player.height; player.onGround true; } else if (player.vy 0) { player.y tile.y tile.height; } player.vy 0; } }先处理 X 再处理 Y 的顺序是有讲究的。因为玩家大部分时间是在水平移动和垂直下落如果先统一处理完再统一解析很容易出现斜向卡入角落的情况。逐轴解析后每个方向单独修正就能保证一个速度方向只修正一个轴不容易把角色挤到奇怪的缝隙里。当然这不是唯一方案很多游戏用 Swept AABB也就是检测运动轨迹的扫掠体。但对于像素平台游戏逐轴解析加合理的移动步长单帧移动不超过最小砖块尺寸的一半已经足够稳定。还有一个防穿墙的关键参数最大下落速度。420 px/s 除以 60 帧是 7 像素而 Caveman 的砖块是 16x16单帧移动 7 像素远小于砖块尺寸所以不会出现高速下坠直接穿透地板的问题。Boss 战里有些强制下落的高速移动是例外那种我用了分段移动把长距离拆成多段逐步检测这个属于特定场景的特殊处理普通关卡用不上。4.3 受击反馈打击感的一半来自敌人的反应往敌人身上扔一根骨头如果敌人纹丝不动、只是血量条减一点玩家会觉得自己在打空气。打击感的构成里攻击方的动画和音效只占一半另一半完全取决于受击方的反馈。Caveman 里我做了三层受击反馈第一层是击退。骨头命中敌人的瞬间敌人会获得一个朝命中方向的反向速度和向上的小弹跳。这个击退让玩家能立刻感知到我打中了而且把敌人打飞出平台边缘还能直接造成坠落击杀这是一个很爽的玩法衍生。第二层是受击动画和闪白。敌人切换到受击帧同时我把它的渲染混合模式临时改成白色高亮每受击一次闪一下。像素游戏里用变色代替复杂的受伤动作是节省美术工时的常见手法。第三层是短暂的硬直敌人进入受击状态后 0.2 秒内不执行攻击逻辑给玩家安全输出窗口。无敌帧放在玩家这边。角色被敌人碰到之后进入 1.2 秒的无敌时间期间角色闪烁不重复扣血。这个机制几乎是动作游戏的标配原因很简单如果没有无敌帧玩家和敌人重叠的每一帧都在扣血玩家可能被一只剑齿虎瞬间耗掉三管血而且毫无反应空间。1.2 秒足够玩家转身逃跑或者反击同时也足够让玩家感到我确实犯了错误才被惩罚。if (!player.invincible) { if (aabb(player, enemy.hitbox)) { player.hp - 1; player.invincible INVINCIBLE_TIME; // 击退方向从敌人指向玩家 player.vx (player.x enemy.x ? 1 : -1) * 200; player.vy -250; } }这里还有个细节值得提一下检测伤害用的是 hitbox攻击判定框而玩家和敌人之间的物理碰撞用的是另一个稍小的 hurtbox受击框。视觉上两个矩形几乎重合但 hitbox 会略大一点让攻击判定更宽容这对游戏手感非常重要。攻击判定宁可是偏大一点也不能偏小——偏小的攻击框会让玩家产生明明打中了却不掉血的愤怒感。很多独立游戏手感发闷罪魁祸首就是碰撞判定框做得太苛刻。5. 关卡生成与难度曲线程序化狩猎场背后的规则5.1 手绘关卡与程序生成的取舍Caveman 里有两类关卡三个手工设计的教程向关卡以及一个程序生成的无限模式。做这个双轨设计时我纠结了很久最后想明白了手绘关卡的优点是能精确控制节奏和教学顺序比如第一关就教玩家按 A 跳跃第二关才放第一只敌人程序生成的优点是不需要美术资源和手工打磨能无限玩下去还能用来测试物理系统的边界。具体分工是这样的手绘关卡负责讲故事——我在地图编辑器里手动摆放悬崖、平台、敌人、道具的节奏点让玩家在 3 到 5 分钟内经历学会走路—学会跳跃—学会战斗—面对第一个小 Boss的完整流程。程序生成关卡则负责重复可玩性——它以 300 像素为单位切分成若干关卡块每块内部用随机规则生成地形和敌人玩家走到下一块就生成新的内容已经走过去的块则销毁回收。这个双轨策略最大的好处是分工明确手感调校和教学节奏归手绘关卡管玩法深度和刷子属性归程序生成管。两边不用互相妥协。5.2 平台块生成用随机数种子的确定性程序生成关卡如果不做控制随机出来的地形很容易出现玩家根本跳不过去的天堑或者一大片无意义的平地。Caveman 的生成规则是先把一个关卡块分成地面段和平台段。地面段永远是连续平地宽度由随机数决定但限制在 120 到 260 像素之间这样既不会让玩家跑得太无聊也不会出现跑不到尽头的跑道。平台段则在地面段上方 40 到 140 像素的高度区间内生成 2 到 4 个浮空平台每个平台宽 64 到 96 像素。关键的一条规则是平台间距必须可跳。根据第 3 节算出来的角色跳跃能力——最大水平跳跃距离约 160 到 180 像素最大跳跃高度约 116 像素——我生成平台的水平间距不会超过 120 像素垂直落差不会超过 80 像素。生成完一个关卡块之后程序再跑一遍校验函数把所有相邻平台的跳跃可行性再查一次不可达的平台就直接删掉。宁可少一个平台也不能让玩家走到一半发现跳不过去。随机数我用了 mulberry32 这个轻量算法function mulberry32(seed) { return function () { seed | 0; seed (seed 0x6D2B79F5) | 0; let t Math.imul(seed ^ (seed 15), 1 | seed); t (t Math.imul(t ^ (t 7), 61 | t)) ^ t; return ((t ^ (t 14)) 0) / 4294967296; }; }用种子生成意味着同一个种子必然产出同一个关卡这对无限模式和后续的验证非常有用——玩家分享一种子就能复现别人的关卡。其实在很多平台跳跃游戏和肉鸽游戏里种子即关卡都是标配Caveman 只是把这个思路做进了网页版成本不高但效果明显。5.3 敌人密度、道具摆放与难度递增程序生成模式下难度不是一个全局数字而是沿着关卡块序列逐步变化的曲线。Caveman 的做法很简单从第 1 块到第 20 块敌人密度从每块 0.5 个敌人线性增加到每块 3 个敌人平台数量则从 2 个增加到 4 个。密度表达式大概是enemyCount floor(0.5 blockIndex * 0.15)。到第 20 块之后密度封顶不再增加因为如果无限增长游戏会在某个节点变得无法通关。道具的摆放我遵循一个铁律道具永远出现在玩家做出一次成功跳跃或战斗操作之后才能到达的位置待在那里获得的回血和加分才有意义。比如悬崖尽头、高平台顶端、刚清理完一波敌人的空地中央。如果道具放在一个毫无威胁的平路上玩家会无脑吃掉既没有正反馈也破坏节奏。还有一个细节是 Boss 战的引入时机。Caveman 的手绘第三关卡结束时会触发 Boss 战Boss 是一只大野猪三阶段血量每一阶段改变攻击模式。Boss 战的本质其实也是难度曲线的一部分——前面的普通关卡是在测试玩家对跳跃和投掷的基本功Boss 战则是把两个基本功叠加在一起要求玩家一边躲避冲锋一边在正确的时机窗口里扔骨头反击。如果前面程序生成关卡里玩家已经养成了站着不动等敌人过来再扔的坏习惯到 Boss 战一定会被打醒。6. 美术和音效用最少资源塑造一个原始世界6.1 像素规格与调色板约束美术是单人程序员最容易拖垮进度的环节所以 Caveman 从一开始就给自己套上了三个约束第一所有图块的基准分辨率是 16x16第二角色精灵是 16x24宽 16高 24两格半第三整个游戏的主色调限制在 16 色以内。这三个约束是什么意思呢意思是我不需要画高清原画只需要在一个非常小的网格上画能认出来的形状然后用有限的颜色去做一致性。调色板我选了暖色调为主的复古组合肤色用浅沙色兽皮用赭石和深棕石头用灰蓝树叶用草绿背景天空用日落黄。限制颜色的最大好处是统一感。哪怕我画画水平只有 60 分只要所有场景都用同一个调色板整体看起来就会像有设计过的体系而不是东一抹西一抹的杂乱。画像素画我用的工具是 Piskel 和 AsepritePiskel 免费在线、够用Aseprite 付费但功能强。对初学者我建议先用 Piskel 画几个 16x16 的小图练手画到你能在 16 像素里表达出一颗树的形状和一块石头的光影再考虑上 Aseprite 做动画帧。6.2 帧动画系统让原始人看起来在跑像素画动起来才有生命力Caveman 的角色动画分四个状态待机3 帧循环、跑动4 帧循环、跳跃2 帧上升和下落、投掷3 帧出手、飞出、收手。每帧是 16x24我把所有帧按顺序拼在一张横向的图集里用一个帧编号定位const ANIMATIONS { idle: { frames: [0, 1, 2], fps: 6 }, run: { frames: [3, 4, 5, 6], fps: 12 }, jump: { frames: [7, 8], fps: 0 }, // 不循环按状态手动切换 throw: { frames: [9, 10, 11], fps: 15 }, };动画播放器维护一个当前帧号和时间累加器到了帧间隔就切下一帧。跑动动画的 fps 我调到 12恰好和角色 220 px/s 的移动速度匹配。如果你动画播得比实际移动速度慢角色看起来像在地上滑冰播得太快又像踩了风火轮。这个协调需要反复试。一个实用的经验把动画帧率和角色速度按比例锁起来跑动动画跑完一整轮的时间应当刚好等于角色前进大约一个身体宽度的距离。跳跃动画我刻意不做成循环——上升和下落各用一帧配合身体的翻转上升时脸朝前下落时四肢展开就足够传达出我在跳的信号。像素游戏的生命力其实不靠帧数多靠的是每个姿态的关键帧抓得准。6.3 用 Web Audio 现炒现卖把音效做进代码里Caveman 没有用现成的音效素材包所有音效都是运行时用 Web Audio API 现场合成的。这有两个好处一是完全避免版权问题二是文件体积几乎为零页面加载速度不受影响。最常用的音效是跳跃音它就是一个频率从低到高的短促正弦波加上一个快速衰减的增益包络function playJumpSfx() { const ctx getAudioCtx(); const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type square; osc.frequency.setValueAtTime(180, ctx.currentTime); osc.frequency.exponentialRampToValueAtTime(420, ctx.currentTime 0.1); gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime 0.15); osc.connect(gain).connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime 0.16); }不同事件的音效用不同波形和频率组合投掷骨头用方波加下滑命中敌人用噪声爆破拾取道具用上行琶音玩家受伤用低沉的三角波下坠。Boss 战音乐则用 Web Audio 的定时器调度一段 4 小节循环的低音线加打击乐强度刻意做得比普通关卡更密集制造压迫感。Web Audio 有一个非常容易踩的移动端大坑浏览器禁止在用户手势之前播放声音所有 AudioContext 创建时默认是 suspended 状态。所以启动游戏时不要立刻 attempt 播放任何声音而是要在用户第一次点击或触摸事件里调用ctx.resume()之后才能正常放音。我在代码里用了一个全局标记第一次捕获到pointerdown或keydown事件时统一 resume 一次。这个细节如果漏掉游戏在手机上会表现为画面正常但完全没声音而且你查半天也查不出渲染代码哪里有问题因为根源在浏览器策略。7. 性能优化、跨端适配和我在这个项目里踩过的坑7.1 性能预算与对象池网页游戏最大的性能隐患来自浏览器的垃圾回收。Caveman 的攻击频率高每一根骨头都是一个对象如果玩家快速连扔几十帧内就会产生大量临时对象触发频繁 GC游戏就会出现周期性卡顿。解决办法是对象池预先分配一批 Bone 对象不用的回收进池子里下次需要时直接取出来复用const bonePool []; function getBone(x, y, dir) { let bone bonePool.pop(); if (!bone) bone new Bone(); bone.reset(x, y, dir); return bone; } function recycleBone(bone) { bone.active false; bonePool.push(bone); }同样的思路也用在了粒子特效和浮字伤害数字上。除了对象池我还做了离屏剔除所有离开相机视野范围超过 64 像素的实体直接跳过更新和渲染。这在程序集成关卡里特别有用因为玩家身后那些已经走过的关卡块不会再产生任何 CPU 开销。还有一点关于 Canvas 渲染的性能经验像素游戏的粒子特效不要用ctx.shadowBlur这类高开销属性一用它浏览器要额外做模糊计算粒子一多帧率立刻掉。用渐变模拟光晕也比 shadowBlur 便宜很多。Caveman 里所有发光的效果都是先画一圈半透明的圆再画小一些的实心圆效果不比阴影差开销低得多。7.2 手机端触控适配Caveman 的目标平台从立项时就包含手机浏览器。触控和键鼠的差异非常大完全复用同一套输入抽象层是不行的。我做了三件事第一在移动端检测到ontouchstart时在画面左下角渲染三个半透明触控按钮左、右、跳右下角渲染一个投掷按钮按钮的按下状态映射到与键盘输入相同的input.left / input.jump等标志位。第二把所有点击和滑动事件换成 Pointer Events 统一处理并加上e.preventDefault()阻止浏览器默认的滚动手势和缩放。第三在 HTML 的 viewport meta 里禁用用户缩放meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno /触控适配做完之后我实际在手机上玩了一阵发现最大的问题不是代码而是手指遮挡屏幕。所以我把按钮的透明度调到了 0.35并且允许玩家在设置里自定义按钮位置。这个功能代码量很小但让玩家长时间握着手机不会太累。对手机玩家来说按钮不挡住画面视野比好看更重要。7.3 复盘三个让我浪费了一整天的 Bug做项目至今有几个 Bug 是真真正正耗掉了我一整天的值得单独拎出来说说因为它们都不是看不懂报错级别的问题而是看起来完全正常但行为诡异的问题。第一个是开头提到过的切后台瞬移。现象很诡异把游戏标签页切到后台过几分钟切回来角色已经站到了地图最右边甚至已经死了。排查了很久才意识到是 requestAnimationFrame 在后台被暂停切回来时会给一个巨大的时间差而我当时没用固定步长也没钳制 delta。修复就是第 2 节里的Math.min(delta, 0.05)加固定步长 accumulator。这个 Bug 让我彻底理解了游戏时间和现实时间的区别也让我养成了所有游戏循环必须钳制 delta 的习惯。第二个是角色卡在墙角疯狂抖动。表现是走进一个角落、顶到墙上时角色会像是被墙嚼了一样左右发抖甚至被弹出来。原因是我在 X 轴和 Y 轴合并做碰撞解析时顺序混乱导致两个轴在角落处反复拉扯每一帧都在把角色往死胡同里推。修复方法就是拆成先 X 后 Y的独立解析并且修正方向时只允许朝速度的反方向修正。这个 Bug 是最典型的碰撞系统写错方向问题排查方式也很粗暴给角色画包围盒调试视图然后一格一格地观察卡住瞬间的位置变化。第三个是像素画发糊。我在桌面浏览器上看着一切正常但换到一台 Windows 高分屏笔记本上打开角色边缘全是毛刺和模糊。原因有两个叠加一是 Canvas 默认开启了 imageSmoothingEnabled像素放大后做了平滑插值二是没有处理 devicePixelRatio导致 Canvas 物理像素尺寸和 CSS 尺寸不匹配。两者齐上阵糊得没法看。修复就是第 2 节里的两行设置加 DPR 缩放。这个 Bug 的教训是像素游戏哪怕纯逻辑写得再好只要画布设置不对整体观感就会瞬间廉价到不行所以像素游戏防糊两件套应该写进每个项目的第一行代码。复盘这三个 Bug我最大的感受是它们每一个在事后看都简单得让人想笑但事发时都足够让你怀疑人生。所以调试 2D 游戏时我强烈建议给所有实体加上调试包围盒的开关按一个按键就能把所有碰撞矩形画出来。很多时候问题不是逻辑不对而是你以为它在的位置和它实际在的位置完全不是一回事可视化一开真相立马浮出水面。做完 Caveman 这个项目我最大的收获反而不是某段代码或某个算法而是对手感这件事有了具体的、可量化的理解。过去我以为手感是玄学是天才设计师一拍脑袋调出来的感觉现在我知道它其实就是一组物理参数、几个输入宽容机制、一套受击反馈的组合结果。你只要愿意花时间拿数据去调任何角色都能调出接近商业作品的操控感。Caveman 现在还能跑在浏览器里我想我会继续给它加内容比如新的 Boss、带刺地形的变化版本、或者一份基于玩家通关次数的简单本地记录。这些扩展都不复杂因为当初搭的架构已经给它们留好了位置。如果你也想做自己的第一一个小游戏我的建议就一句话先定义好完成的样子然后从游戏循环开始一帧一帧地把世界搭出来。