原生 Canvas 也能做网页游戏?caveman 生存游戏开发全解析
“caveman”这名字听起来就像是从复古卡带里蹦出来的。我最近把它用在一个个人小项目上,做成了一个网页端的原始人生存模拟游戏。你不用装引擎也不用配环境,打开浏览器就能玩:你扮演一名穴居人,白天去采浆果、打野兔、捡树枝生火,晚上躲回山洞防野兽,目标是撑过 30 天。这个项目的定位很纯粹:一方面想验证一下“不依赖现代前端框架,只用原生 Canvas 能不能做出一款完整可玩的游戏”;另一方面,也是想用最“原始”的方式过一次独立游戏开发的手瘾。适合谁看呢?想用原生 JS 练手的同学、对游戏数值设计感兴趣的玩家,还有那些一打开编辑器就想先 npm install 一堆依赖的人,这篇内容应该都能让你有一点收获。
1. “caveman”是什么:一个故意返祖的生存小游戏
1.1 一句话定位:不是大制作,是“可玩”优先
caveman 的核心玩法当天就定下来了:玩家扮演一个住在山洞里的原始人,在 30 天内靠采集、狩猎、生火和休息活下去。每天分成两个阶段——白天出门搜集资源,晚上回到山洞用资源换取生存机会。如果生命值归零,或者第 30 天结束时部落人口没有达到目标,游戏就判定失败。
这里有个很关键的取舍:我不打算把它做成《饥荒》那种高度拟真的生存沙盒,而是做成“一局半个小时能玩完”的回合制小游戏。因为项目只有一个人开发,周末两天要是把精力全耗在无缝地图、多人同步、复杂合成系统上,大概率连一个能跑通的 Demo 都拿不出来。所以我给项目定的规格非常克制:单主角、单地图、回合制、像素风、纯本地存储。所有资源包压缩到一顿晚饭的时间能消化完,剩下的火力集中在“玩起来有没有意思”。
1.2 为什么叫 caveman,又为什么用“原始”开发方式
项目名 “caveman” 有双关意思。游戏题材是穴居人,而整个开发过程也刻意向“原始状态”靠拢:不碰框架、不用构建工具、不引入任何运行时依赖。打开编辑器就是一个 HTML 文件配上两个 JS 文件,写完直接扔浏览器跑。
这听起来像自虐,实际上是一种非常解压的写代码方式。现在很多前端项目上来先装 200 MB 的 node_modules,改一行样式都要等编译。开发 caveman 的时候,我完全绕开这些,改完刷新页面立刻能看到结果,调试也从“读编译报错”回到“看打印日志和肉眼找像素块”。这种体验让我找回早期写网页脚本时那种直接、快速、不受工具链绑架的感觉。
用原生 Canvas 还有一个现实原因:这个项目的核心是 2D 矩形碰撞、简单的状态机和定时随机事件,这些用浏览器 API 完全够了。引入 Phaser 这样的游戏引擎当然更省事,但对一个体量这么小的游戏来说,引擎本身的体积和学习成本反而会成为干扰。做个人项目,刻意“少用东西”有时候比“堆满东西”更难,也更有价值。
1.3 项目整体结构一览
我把代码分成三个文件,每个文件职责单一,这也是个人项目里保持清醒的关键:
| 文件 | 职责 | 核心内容 |
|---|---|---|
| index.html | 页面骨架 | Canvas 画布、HUD、消息面板、开始/结束界面 |
| game.js | 核心逻辑 | 状态机、回合流程、资源计算、随机事件、存档 |
| render.js | 绘制层 | 背景、角色、野兽、树木、山洞、昼夜特效 |
这样一来,逻辑和渲染完全分离开。我白天调数值的时候不用去翻绘制代码,晚上修渲染 bug 的时候也不会误伤游戏状态。哪怕后面想加场景、加角色,只需要往 render.js 里塞新的绘制函数,game.js 那套状态机完全不用动。
2. 核心玩法循环:白天是猎人,晚上是猎物
2.1 为什么选择“回合制”,而不是即时制
生存游戏常见的做法是即时制,角色随时可以走动,野兽随时会追过来。但即时制会引入一连串躲不开的问题:角色的加速度、野怪寻路、地图遮挡关系、帧率波动对判定影响,这些都得单独处理。项目只有两天时间,我不想把周末贡献给路径规划算法。
所以我选了“回合制 + 时间片”的混合方案。整个白天的行动被切成若干个行动点,每个行动点消耗 1 个小时,玩家可以从“采集、狩猎、砍柴、探索、返回山洞”里选一个动作。选完不暂停,立刻结算结果,然后进入下一个行动点。这套循环的逻辑像极了桌游:你做一个决定,系统告诉你发生了什么事,你再做下一个决定。非常容易实现,也非常容易调平衡。
回合制还有一个隐性好处:玩家有足够时间看清屏幕上的每个像素。即时游戏节奏一快,很多细节根本来不及观察,而 caveman 需要在 30 天里慢慢积累资源、形成策略,回合制正好匹配这个节奏。
2.2 资源数值设计:让每个选择都有代价
我设计了四个核心数值:生命值、饱食度、温度、火堆状态。前两个比较好理解,温度是一个偏门但很重要的指标——如果夜晚没有生火,角色体温会持续下降,降到临界点就会扣血。
数值范围设计成 0~100,方便显示进度条。经过十几轮试玩,我最终把每天的行动点定为 8 个小时,白天一共 16 小时,玩家在“出门”和“回山洞”之间最多能执行 6 次行动,剩下 2 小时用于往返。这样玩家每次都必须在“再采集一次”和“留够时间回山洞”之间纠结,策略深度立刻就有了。
各行为的数值变化大概这样:
| 行为 | 饱食度 | 生命值 | 温度 | 收获 | 风险 |
|---|---|---|---|---|---|
| 采集浆果 | +2 | 0 | 0 | 食物+3 | 低 |
| 狩猎 | +1 | 0 | 0 | 食物+5 | 中,可能受伤 |
| 砍柴 | 0 | 0 | 0 | 木头+2 | 低 |
| 探索 | -1 | 0 | 0 | 随机物品/事件 | 不定 |
| 生火休息 | -2 | +5 | +20 | 消耗木头+1 | 无 |
这些数值不是拍脑袋定的。我当时的想法是:必须让“狩猎”看上去收益最高,但风险也最明显;必须让“采集”成为安全兜底,但效率更低;必须让“砍柴”本身不产生食物,却决定你晚上能不能安全度过。玩家只有在三种行为间反复横跳,才可能撑过 30 天。
2.3 黑夜阶段:把白天攒的资源变成生存概率
夜晚的处理也走简化路线。进入夜晚后,玩家不能自由移动,只能看到山洞里的一堆画面,系统根据“是否有火、是否有食物、是否制作了木矛”这几个条件,计算野兽袭击和体温下降的概率。
这个设计让白天和夜晚形成了天然的钩子:白天收集木头不只是为了取暖,还决定了晚上会不会被狼摸进来;白天猎到的肉如果没吃完,第二天会变质,所以必须精打细算地吃掉;白天发现的燧石可以用来升级工具,工具又能提高狩猎成功率。资源之间的耦合关系越多,玩家就越难无脑重复同一个动作。
夜晚结束后的早晨阶段,系统还会生成一个随机事件,比如“暴风雨破坏了你的山洞入口,木头-1”“远处传来兽群的声音,今天狩猎风险提高”“你发现了一颗巨大的野生果树,采集收益翻倍”。随机事件表我写了二十多条,目的是让每次 30 天旅程都不完全一样。事件概率用简单的加权随机实现,权重根据当前天数动态调整,越到后期凶险事件出现概率越高。
3. 关键模块的代码级拆解
3.1 用一台状态机管住整个游戏流程
caveman 的所有流程都可以用状态机描述:BOOT、MENU、DAY_ACTION、DAY_END、NIGHT、GAME_OVER、WIN。我把状态机定义放到了 game.js 最前面,方便所有函数引用。
const GameState = { BOOT: 'BOOT', MENU: 'MENU', DAY_ACTION: 'DAY_ACTION', DAY_END: 'DAY_END', NIGHT: 'NIGHT', GAME_OVER: 'GAME_OVER', WIN: 'WIN' };在每一帧的 update 函数里,我只做一件事:根据当前状态调用对应的处理函数。
function update(dt) { switch (game.state) { case GameState.DAY_ACTION: updateDayAction(dt); break; case GameState.DAY_END: updateDayEnd(dt); break; case GameState.NIGHT: updateNight(dt); break; case GameState.GAME_OVER: updateGameOver(dt); break; } }这个写法最大的价值是“强制隔离”。白天行动阶段绝对不会不小心触发夜晚的野兽袭击判定,夜晚的体温扣除也不会漏到白天逻辑里去。后来我往游戏里加功能,几乎所有改动都只碰某个状态对应的更新函数,几乎没有出现过跨状态的连带 bug。
3.2 玩家移动和地图碰撞:AABB 就够用
地图做成了 480×320 的逻辑尺寸,按像素风渲染放大两倍。玩家、树木、石头、野兽都是矩形碰撞体,碰撞检测直接用 AABB(轴对齐包围盒)那套经典算法:
function hitTest(rectA, rectB) { return rectA.x < rectB.x + rectB.w && rectA.x + rectA.w > rectB.x && rectA.y < rectB.y + rectB.h && rectA.y + rectA.h > rectB.y; }我没有给角色做瓷砖地图或者四叉树空间索引。原因很简单:场景里的实体数量最多也就二十来个,每一帧做两两检测不过几百次比较,性能完全够用。与其提前优化,不如把时间留给玩法。做小游戏最重要的一条经验就是——性能优化永远等到确实卡了再说。
移动方面,玩家通过键盘方向键操作,按住不放会持续朝那个方向走。为了让移动手感不飘,我在 render.js 里加了简单的摩擦力:速度以每秒 120 像素为目标,实际位移通过插值靠近目标值,这样角色从静止到奔跑会有个短暂的加速过程,看起来会自然很多。
3.3 野兽 AI:一只只会直线冲锋的“原始狼”
野兽 AI 也是刻意做得笨的。狼的行为只有三种状态:漫游、发现玩家、逃跑。漫游时在地图下半部分随机晃悠,发现玩家后直接朝玩家当前位置直线冲刺,冲到身边就发动攻击。启动攻击前会有一个短暂的“龇牙”停顿,给玩家反应时间。这个“停顿”非常关键,否则狼一旦出现就秒杀,玩家会觉得自己是被随机数干掉的。
function updateWolfAI(wolf, player, dt) { if (wolf.mode === 'roam') { wolf.x += wolf.vx * dt; if (Math.random() < 0.01) { wolf.vx = (Math.random() - 0.5) * 40; wolf.vy = (Math.random() - 0.5) * 40; } } else if (wolf.mode === 'chase') { const dx = player.x - wolf.x; const dy = player.y - wolf.y; const dist = Math.hypot(dx, dy); wolf.x += (dx / dist) * 80 * dt; wolf.y += (dy / dist) * 80 * dt; } }直线冲锋会让狼在撞到树之后卡住,但这恰好成为了玩法的一部分:玩家可以利用树木当掩体绕圈走位,狼会因为疯狂撞树而露出破绽。这种 AI 缺陷反而制造了“操作空间”,比费大力气写的寻路算法更适合这个小体量游戏。
3.4 昼夜切换和 Canvas 绘制
昼夜是渲染层最重要的视觉信号。白天用暖黄色调,夜晚用深蓝色,中间用半小时过渡动画,而不是瞬间切换。过渡通过一个 nightly 变量实现,范围从 0(白天)到 1(深夜),每一帧根据当前游戏时间计算。
function drawBackground(ctx) { const r = Math.floor(235 - 150 * game.nightly); const g = Math.floor(215 - 120 * game.nightly); const b = Math.floor(150 - 80 * game.nightly); ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; ctx.fillRect(0, 0, canvas.width, canvas.height); }夜晚时,可见范围缩小:我盖了一层半透明黑色遮罩,然后用“destination-out”挖出一个圆形光圈,光圈内部是正常视线,外部全黑。这样不仅渲染出“烛火照明”的感觉,还能让玩家一眼看出天黑后视野受限,强化“必须回山洞”的紧张感。实现光照遮罩也很简单,不需要任何 WebGL:
ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = 'rgba(10, 10, 30, 0.85)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.arc(lightX, lightY, 70, 0, Math.PI * 2); ctx.fill();这个技巧够用到任何 2D 小游戏里,而且代码量极少。如果你也想做像素风格的小游戏,建议先画矩形版本,确认玩法好玩以后再考虑加光照、粒子这些视觉糖霜,千万别在核心循环没通之前陷进视觉效果里。
4. 实测中踩过的坑:性能、触控和“太难了”
4.1 卡顿?问题出在重绘上,而不是计算
第一个可玩版本跑起来以后,最明显的毛病是画面掉帧。我开始以为是碰撞计算太慢,后来用浏览器自带性能面板一看,耗时大头全在 Canvas 重绘上。因为我每一帧都会把整个画布重新填色、重新绘制所有树木和地表纹理,即使有几十个不动的静态对象也全部重画一遍。
解决方法非常原始:把静态地形先绘制到一个离屏 Canvas 上,之后每一帧只用 drawImage 把静态背景贴上去,动态人物、野兽再单独画。这招效果立竿见影,FPS 从三十几直接稳到六十。
// 初始化时一次性绘制背景 staticCanvas = document.createElement('canvas'); staticCanvas.width = 480; staticCanvas.height = 320; drawStaticBackground(staticCanvas.getContext('2d')); // 每帧先贴背景,再画动态对象 ctx.drawImage(staticCanvas, 0, 0); drawPlayer(ctx); drawWolves(ctx); drawParticles(ctx);这个优化思路对所有 2D 游戏都通用:把“不变的图层”和“变化的图层”分开,别让静态元素重复消耗帧预算。
4.2 手机上没法玩:触控适配是大坑
最初版本只考虑键盘,结果一放到手机上就露馅:没有手柄,也没做任何触控按钮。后来我在屏幕上加了一个虚拟摇杆:左下角画一个圆形底托和一个可以拖动的圆钮,监听 touchstart、touchmove、touchend,把拖动的偏移量映射成角色移动方向。
触控和鼠标事件有个容易踩的深坑:touchmove 默认会触发页面滚动,还会让 Canvas 区域发生缩放。必须对画布所在的元素调用 preventDefault,并且加上 CSS touch-action: none。
canvas { touch-action: none; user-select: none; }这个改动之后,手机端才真正能玩。个人项目开发时,我强烈建议从一开始就把触控当第一输入设备来做,不要等桌面版做完再补。补适配的代价远高于一开始做双输入。
4.3 数值失衡:第一天死了二十次
第一轮测试,我自己玩都是连续暴毙。原因在于夜晚温度下降得太快,而木头的获取量又太少。我连续调了三组参数:把初始木头从 0 改成 2,把砍柴每次获得的木头从 1 改成 2,再把夜晚温度下降速度从每回合 15 点降到 8 点。这才让第一晚存活率超过 50%。
“难度曲线”不能靠感觉,一定要记录下来。我做了个简单的表格,记录每一天测试中玩家的生命、饱食、温度,然后用“是否感觉资源被卡死”作为评判标准。调整数值的原则也很朴素:前期允许玩家犯错但不致郁,中期增加随机压力,后期看玩家能否把资源正向循环运转起来。如果你也做生存游戏,记住一个口诀:第一晚别杀人,第一周别劝退,资源卡脖子要慢慢来。
4.4 存档把进度给吞了
因为想要“随时关掉浏览器再回来继续”,我做了一个 localStorage 自动存档,每天结束时写入。第一次测试发现,第二天恢复存档后角色位置直接跑到了地图外面,数值全部变成 NaN。
排查后发现是存档时没有做数据校验,存储 JSON 里的坐标、数值偶尔会写成 undefined。解决方式是写一个 save 函数和一个 load 函数,load 时对每个字段做一次默认值兜底和类型检查:
function loadGame() { const raw = localStorage.getItem('caveman_save'); if (!raw) return null; const data = JSON.parse(raw); return { day: validInt(data.day, 1), hp: validInt(data.hp, 100), stamina: validInt(data.stamina, 100), wood: validInt(data.wood, 0), food: validInt(data.food, 0), x: validNumber(data.x, 240), y: validNumber(data.y, 160) }; }不要相信任何旧存档的字段完整性。版本迭代后字段可能变少、变多,类型也可能变化,不加校验就会把用户辛苦打的进度废掉。这是做本地存档最实际的教训。
5. 发布上线与玩家反馈实录
5.1 部署三步:便宜、快、省心
这个项目没有后端,部署静态文件就够了。我的上线流程很简单:把 HTML、CSS、JS 上传到一个静态托管目录,终端跑一条同步命令,然后访问返回的网址就开始试玩。整个过程不到五分钟。只要没有数据库、没有用户系统,个人小游戏完全可以走这条路,不要一上来就搭服务器。
caveman 只有三个文件,总大小不到 90 KB,加载几乎瞬间完成。这种轻量也是刻意做出来的——不引入任何 UI 库,进度条用 Canvas 自己画,字体用系统默认的 monospace,音效用 Web Audio API 临时生成的“哔哔”声。自己生成音效其实是意外收获,比找外部音频文件省事得多,还能规避素材授权问题。
5.2 玩家反馈里最有用的三个声音
内测阶段我给朋友发了一个链接,收集到三条反馈直接影响了最终版本。
第一条是“前期不知道干嘛”。最开始没有新手引导,玩家站在原地不知道采集要走到树旁边。我加了一段极简引导:第一天的前三个行动点用高亮虚线框圈出可交互物体,并显示“走近树采集果实”的提示文字。这个改动把新手的懵圈时间从五分钟缩短到二十秒。
第二条是“到后期无聊了”。虽然随机事件能带来新鲜感,但第 20 天之后行为开始重复。我给后期增加了一个特殊事件:发现神秘洞穴,可以选择进入并获得一个随机强力遗物,比如“石斧:狩猎成功率提升 30%”。这个钩子让后期游戏有了明确的追求。
第三条是“想看结局故事”。原版只有“你活过了 30 天”一行大字。我后来给胜利场景加了一段简单的文本故事:主角带领部落走出了山谷,发现山下有一条大河。没有图片,纯文字,但玩家反馈说“感觉完整多了”。很多时候,一个简单的情感落点,比复杂的成就系统更有效。
5.3 后续如果继续做,我会扩展什么
caveman 目前的体量当一个周末项目已经完全合格,但真要往完整作品方向走,我会优先做三件事:增加第二个地图,地图之间的物资差异让探索选择变得更有意义;增加 NPC 伙伴,解决单人行动时孤独感太强的问题;添加“季节循环”,让 30 天不再是一条直线上升的资源曲线,而是一年四季的生存考验。
我最想做的是把“食物保质期”这个系统深化。现在食物只有 24 小时变质,如果能区分浆果和肉类,肉需要烟熏保存,浆果可以酿成饮品,那资源的深加工链就会自然形成。不过这些明显不是一两天能做完的,留给以后随缘更新。
6. 最后一点实操心得
写 caveman 这个过程,我最大的体会是“少即是多”在个人项目里比想象中重要。不装依赖、不做过度设计,每天只聚焦一个核心玩法循环,你反而更容易把一个项目从零做到能发布。那些复杂的工具链和炫酷的引擎特性,都放在玩法跑通之后再考虑也不迟。
如果你也想做类似的小游戏,我建议你把范围再缩小一点:先做一个只有一天时长的 Demo,让玩家在一分钟内完成“采集—生火—遇袭—存活”的最小循环。跑通以后再加天数、加地图、加随机事件。千万不要第一天就把三十天数据填完,你会在平衡性调整上把自己累死。最后分享一个小经验:随手记录每一次数值调整前后的玩家反馈,比任何测试工具都管用。caveman 的最终数值,就是靠几十行手写的测试笔记一点点磨出来的。