不用框架也能做游戏:原生JavaScript手写放置游戏Caveman全记录

前阵子动手做了个小项目,名字叫 Caveman。名字的字面意思是穴居人,做完之后我觉得它更像一种状态:抛掉所有现代工具,只用最原始的方式把东西造出来。这个项目是一个只用原生 HTML、CSS 和 JavaScript 写成的“原始人生存放置游戏”,没有框架、没有构建工具、没有 node_modules,压缩完也就十几 KB,双击就能打开。这篇文章不打算讲大道理,就是把 Caveman 从玩法设计、数值推演、代码实现到踩坑排查的完整过程原样记录下来,给正在纠结“脱离框架还能不能做东西”的人一个参考。

如果你刚学前端,可以用它练手;如果你是写了很多年业务代码、天天被框架轮着转的工程师,它更像一次基本功体检;就算你只是喜欢玩放置游戏,里面那套数值成长和事件设计,也值得拆开看看。

1. Caveman 项目到底在做什么

1.1 名字背后的双重含义

Caveman 这个名字,我第一次看到时以为是什么考古相关的项目,真正把它做出来才体会到,它其实可以有两层意思。字面层,它是一个“原始人生存放置游戏”:扮演一个穴居人,在洞穴里采集食物、收集木材、打磨石头、学会生火,最后在这片残酷的野外活过七天。技术层,它是我刻意做的一次“返祖”实验:整个项目不引第三方库、不搞构建工具,全部代码就两个文件,一个 HTML 一个 JavaScript,不能再多了。

为什么一定要这么别扭?因为我和很多人一样,已经太习惯打开 create-react-app、npm install 一条龙,然后离开脚手架就不知道该怎么下笔。框架自然是好东西,但它最大的副作用,是把你和底层原理隔得越来越远。做 Caveman 的过程,等于把自己扔回“只有石斧、没有电锯”的年代,逼着用最原始的工具,重新把房子一间一间搭起来。

如果说框架是电锯,那原生 JavaScript 就是石斧。电锯能快速放倒一整片林子,但石斧能让你看清每一条木纹是怎么走的。做完这个项目再回头看 React、Vue 的响应式机制,理解完全不一样,因为它们解决的“自动更新页面”问题,此刻你已经知道手动做一遍是什么手感了。

1.2 这个项目到底适合谁

我要先把话说清楚,Caveman 不是奔着极致性能和视觉表现去的,它适合一群特定的人。如果你刚开始接触前端,拿它练手非常合适,因为它覆盖了事件绑定、定时循环、JSON 序列化、localStorage 读写这几个前端躲不开的基础点,而且每一样都能在一个周末内跑通,正反馈来得很快。你不需要懂任何框架知识,有一个文本编辑器就能开始。

如果你是个写了几年业务代码、天天和框架打交道的开发者,这个项目更像一次“基本功体检”。我个人做完最大的感受是:那些平时被封装的逻辑,重新自己写一遍一点也不可怕,只要分层清楚,几十行代码就能撑起一个完整小游戏。至于纯粹的游戏爱好者,也别失望,后面分享的数值曲线、事件设计、存档机制,很多商业放置游戏其实也是同一套思路。一句话总结定位:这是一个“最小可行系统”的实验,目标是让一个外行人打开文件就能玩,让一个开发者读完代码就明白所有东西是怎么造出来的。

2. 玩法与数值:先说清楚怎么才能好玩

打开一个新的放置游戏,前三十秒基本决定用户留不留下来。所以我写 Caveman 的时候,第一件事不是写代码,而是把数值在纸上推演了一遍,这个习惯强烈建议想自己做游戏的朋友养成。玩法再花哨,数值不动人,游戏就是死的。

2.1 三条资源线,一条生存线

游戏里主资源只有三种:食物、木材、石头,外加一条生命值。食物既是资源也是生存压力:初始 30 食物,静止状态下每秒消耗 0.2,也就是说如果不做任何操作,150 秒后就开始饿。徒手采集是每秒 0.5 食物,扣掉消耗后净收入 0.3。这个数值是有意设计的,玩家刚进入游戏时手动点几下“去采集”按钮,马上能看到食物上涨,正反馈立刻就建立起来了。

木材和石头不会由徒手采集直接获得,必须通过工具解锁。我把鼓励方向做得非常直白:想要更高的产出,先拿木材升级工具,升级后木材产出变大,再拿更多木材换石头。于是形成了一条清晰的循环链:采木头、造石斧、更快采木头、换石头、生火、保命。各阶段产出最终定成了这样:

阶段解锁条件食物产出木材产出说明
徒手无+0.5/s0唯一安全选择
木棒10 木材+0.6/s+0.4/s第一个升级
石斧40 木材 + 20 石头不变+1.2/s木材起飞
火堆30 木材 + 50 石头食物 x2不变烹饪与保暖
长矛60 木材 + 40 石头不变不变降低野兽威胁

细心的读者会发现,火堆不是“产出+0.5”这种加法,而是直接把食物产出乘二。乘法和加法在放置游戏里的手感差异非常大:加法是线性成长,乘法是复利成长,前者玩到后面越来越乏味,后者哪怕挂机回来一看,也能直观感受到“我变强了”。生存线是生命值:如果食物降到 0,生命每秒扣 1,生命归零游戏重置。饥饿机制放在这里还有一个隐藏用途,它让“离线收益”有了上限,也逼着玩家学会分配——是选择多屯食物防空窗期,还是提前升级长矛抵御野兽?这个二选一,就是放置游戏里少见的策略味道。

2.2 指数成本曲线:放置游戏的手感来源

数值成长的另一半是成本曲线。几乎所有像样的放置游戏都在用指数曲线,Caveman 也不例外。规则很简单:第 n 次升级的成本,等于基准成本乘以 1.3 的 n 次方,最后取整。

cost(n) = round(baseCost x 1.3^n)

举个例子,如果某个“采集效率提高”升级的基准成本是 10 木材,那第 1 级是 10,第 2 级是 13,第 3 级是 17,第 4 级是 22,到第 10 级就在 106 左右,从“10 就能买”到“100 多才买”,体验差距其实不大。但如果往后走到 25 级,成本会跳到几千。这种指数膨胀的妙处在于,它托住了游戏的节奏曲线:刚开始几十秒一个升级,高频反馈把人拉进来;中后期一个升级可能要挂机半天,玩家不至于几分钟内把内容全部耗尽。

当时最纠结的是指数底数该取多少。底数太小比如 1.1,后期升级太快,内容两小时就打通;底数太大比如 2,前期第 5 级就要等几小时,新手直接劝退。最后选了 1.3,配合 0.3/s 的基础净产出,实测下来:前十分钟能完成 8 到 10 次升级,一个晚上大概能点到 20 级左右,周末两天基本能走完全部科技树。这个节奏对个人小项目来说刚刚好,后面想做放置游戏的朋友,建议也从这个底数开始试,再按自己的手感微调。

2.3 事件系统:给纯挂机加点意外

纯挂机刷数字最容易让人犯困,所以 Caveman 加了一个事件层。实现上没有搞复杂的状态机,就是按“游戏日”结算:每 3 分钟算一天,每天有 15% 概率触发一次随机事件,从下面的表里按权重抽一个。

事件权重效果应对
野兽袭击20无长矛时损失 5 生命或 20% 食物造长矛
松鼠偷吃15食物损失 10%无,纯干扰
暴雨10未来一小时内产出降半等待或主动点按钮
发现浆果丛15食物 +30纯奖励
无事发生40无无

事件表的权重不是随便拍的。好事和坏事要保持大致均衡,否则玩家要么觉得无聊,要么觉得被针对。“无事发生”占四成权重,是给玩家留喘息空间——放置游戏的底色是舒适感,意外是调味剂,不能反过来把舒适感淹没。真正让事件有分量的,是给玩家留出“对策选项”,比如野兽事件对应长矛。没有对策的事件只有惊吓没有趣味,我后来删掉了好几个这种设计,因为测试时明显感觉它带来的不是紧张感,而是烦躁。

3. 从零手写:核心模块的落地过程

讲完纸面上的设计,来说实际代码。我按 Caveman 开发时的顺序走一遍,从页面骨架到游戏循环,再到存档,每一块都会给出可复制的代码和对应的取舍理由。

3.1 单页结构:一屏之内放下所有交互

Caveman 的文件结构极其朴素,一个 index.html 包含样式,一个 game.js 包含逻辑。HTML 只做四块:资源面板、操作按钮区、状态栏、事件日志。页面结构保持在一屏内,是因为这个游戏本身就要在手机浏览器上跑,复杂的布局反而拖累体验。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Caveman - 原始人生存日志</title> <style> body { background: #1d1a17; color: #e8dcc8; font-family: sans-serif; margin: 0; padding: 1rem; } #app { max-width: 560px; margin: 0 auto; } #resources { display: flex; justify-content: space-between; background: #2b251d; padding: 0.8rem; border-radius: 8px; } button { padding: 0.6em 1em; margin: 0.2em; border: 1px solid #5a4f3d; background: #3a332a; color: #e8dcc8; cursor: pointer; } button:disabled { opacity: 0.5; cursor: not-allowed; } #log { height: 8em; overflow-y: auto; border-top: 1px solid #5a4f3d; margin-top: 1rem; padding-top: 0.5rem; font-size: 0.9em; } </style> </head> <body> <main id="app"> <h1>Caveman 原始人洞穴</h1> <section id="resources"> <span>食物 <b id="food">30</b></span> <span>木材 <b id="wood">0</b></span> <span>石头 <b id="stone">0</b></span> <span>生命 <b id="hp">10</b></span> <span>第 <b id="day">1</b> 天</span> </section> <section id="actions"> <button>let lastTime = performance.now(); function frame(now) { let dt = (now - lastTime) / 1000; lastTime = now; dt = Math.min(dt, 1); // 防止切回页面时 dt 爆炸 tick(dt); requestAnimationFrame(frame); } requestAnimationFrame(frame);

tick 里做的事很朴素:把每种资源的产出率乘以 dt 加到对应数值上,把食物消耗率乘以 dt 从存量里扣掉,食物归零就掉生命。同时还有一个关键细节,dt 一定要做Math.min(dt, 1)的钳制。用户切到别的标签页再切回来,两次 frame 的间隔可能是十几秒甚至几分钟,如果不钳制,游戏会瞬间把十几秒的产出全算上,数值跳变非常突兀。钳到 1 秒以内,配合后面的离线收益,体验才会顺滑。

const state = { food: 30, wood: 0, stone: 0, hp: 10, day: 1, dayTimer: 0, tools: { stick: false, axe: false, fire: false, spear: false }, rates: { food: 0.5, wood: 0, stone: 0 }, consumption: 0.2, savedAt: Date.now() }; function tick(dt) { state.food += (state.rates.food - state.consumption) * dt; state.wood += state.rates.wood * dt; state.stone += state.rates.stone * dt; if (state.food <= 0) { state.food = 0; state.hp -= dt; if (state.hp <= 0) { resetGame(); return; } } else { state.hp = Math.min(10, state.hp + 0.1 * dt); } state.dayTimer += dt; if (state.dayTimer >= 180) { state.dayTimer -= 180; state.day += 1; rollEvent(); } render(); }

3.3 升级与操作:用事件委托统一处理

按钮点击的处理也走“状态驱动”这条路。监听 actions 容器,拿到>document.querySelector('#actions').addEventListener('click', e => { const btn = e.target.closest('button[data-action]'); if (!btn) return; handleAction(btn.dataset.action); render(); }); function handleAction(action) { switch (action) { case 'gather': state.food += 1; break; case 'stick': if (state.tools.stick || state.wood < 10) return; state.wood -= 10; state.tools.stick = true; state.rates.food += 0.6; state.rates.wood += 0.4; log('你削了一根结实的木棒'); break; case 'axe': if (state.tools.axe || state.wood < 40 || state.stone < 20) return; state.wood -= 40; state.stone -= 20; state.tools.axe = true; state.rates.wood += 1.2; log('石斧在手,砍树快多了'); break; case 'fire': if (state.tools.fire || state.wood < 30 || state.stone < 50) return; state.wood -= 30; state.stone -= 50; state.tools.fire = true; state.rates.food *= 2; log('火堆升起来了,夜里不再凉'); break; case 'spear': if (state.tools.spear || state.wood < 60 || state.stone < 40) return; state.wood -= 60; state.stone -= 40; state.tools.spear = true; log('手握长矛,心里踏实多了'); break; } }

所有升级都是布尔型一次性消耗,不适合用复杂图。真正值得说的是检查条件的写法:我用的是“条件不满足就 return”,而不是把按钮禁用掉。禁用按钮只是视觉反馈,逻辑上的最终裁决必须回到 state 本身。如果不这么做,事件日志或者快捷键触发就会绕过界面限制,产生负数资源。这个原则我在后面排查时还会再讲,它是个人项目里最容易犯、也最容易忽略的坑。

3.4 存档与离线收益:关掉浏览器之后怎么办

放置游戏最迷人的时刻之一,是关掉页面几个小时再打开,看到资源涨了一大截。要做到这一点,必须有可靠的存档和离线结算。存档我选了 localStorage,key 命名为 caveman_save_v1。为什么带 v1?因为存档格式一定会变,一旦格式升级,旧存档要么没法读,要么读了缺字段,带版本号至少能区分“老格式缺字段”和“新格式文件损坏”。保存时机是每 5 秒一次,加 beforeunload 时强制写一次。5 秒间隔是因为 localStorage 是同步 API,写太频繁会阻塞主线程。

const SAVE_KEY = 'caveman_save_v1'; const DEFAULT_STATE = { food: 30, wood: 0, stone: 0, hp: 10, day: 1, dayTimer: 0, tools: { stick: false, axe: false, fire: false, spear: false }, rates: { food: 0.5, wood: 0, stone: 0 }, consumption: 0.2 }; function save() { state.savedAt = Date.now(); localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } function load() { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return; const saved = JSON.parse(raw); Object.assign(state, DEFAULT_STATE, saved, { tools: { ...DEFAULT_STATE.tools, ...saved.tools } }); applyOfflineGain(); } function applyOfflineGain() { const elapsed = Math.min((Date.now() - state.savedAt) / 1000, 8 * 3600); if (elapsed < 5) return; const gainFood = Math.max(0, state.rates.food - state.consumption) * elapsed * 0.5; const gainWood = state.rates.wood * elapsed * 0.5; const gainStone = state.rates.stone * elapsed * 0.5; state.food += gainFood; state.wood += gainWood; state.stone += gainStone; log(`你离开了 ${formatDuration(elapsed)},靠本能获得食物 ${fmt(gainFood)}、木材 ${fmt(gainWood)}、石头 ${fmt(gainStone)}`); }

离线收益我统一打了五折,原因很实际:如果不打折,玩家会养成“睡觉前把速度拉满”的强策略,游戏变成比谁挂机久,而不是比谁的策略好。打折之后,离线只是回馈,不是主要成长来源。上限 8 小时同理,是为了防止有人靠改系统时间来刷收益,也算是给存档加了一点点防滥用的意思。read 时用 Object.assign 把默认状态和旧存档合并,既保证新版本加字段时老存档也能读,又不会让缺失字段变成 undefined。

4. 常见问题与调优记录

代码写起来总比想象中顺,真正花时间的反而是一堆看起来不起眼的小问题。下面这四条是 Caveman 开发中最常见的坑,每一条我都实际撞过,也把修复手法记录了下来。

4.1 数字膨胀之后显示很乱

放置游戏挂几个小时,食物就会变成几十万。直接显示原始数字,版面乱成一团,而且浮点数精度问题也会冒出来:挂机一晚上,食物内部值可能是 123456.78999998,直接展示非常丑。我的做法是显示时向下取整,超过一万再用紧凑写法。

function fmt(n) { n = Math.floor(n); if (n < 1e6) return n.toLocaleString(); const exp = Math.floor(Math.log10(n)); const mantissa = n / Math.pow(10, exp); return mantissa.toFixed(1) + 'e' + exp; }

记住一个原则:显示值只是给人看的,内部所有判断必须用原始浮点值。我见过有人偷懒,把购买条件写成if (woodEl.textContent >= 10),结果 9.999 被显示成 9 就点不了,或者 0.9999 被显示成 1 反而能买两次,这些都是血泪教训。Caveman 里我统一在 render 阶段调用 fmt,游戏逻辑里永远操作 state 上的 number。

4.2 切后台之后游戏“停了”

浏览器对后台标签页的节流是保底机制,requestAnimationFrame 在页面不可见时基本不跑,游戏看起来就像被冻结了。解决办法分两层:一是存档里的 savedAt 时间戳一直在记录,读取时能感知离线时长;二是窗口重新可见时立刻刷新一次界面数值,不要等下一次 rAF 慢慢恢复。我在代码里加了一个 visibilitychange 监听,切回来时把当前时间存进 state,方便下一次离线计算。这样后台挂多久,收益就精确结算多久,也不会出现“切回标签页先卡两秒”的尴尬。

4.3 连点按钮导致资源变负

早期版本想省事,在 handleAction 里只做了一次扣减,然后依赖 render 去禁用按钮。结果测试时发现快速双击,第二次点击时禁用状态还没来得及更新,同一个升级被扣了两次,木材直接变负。修复方法很简单:扣减和检查必须在同一个同步函数内完成,并且每个升级都要有防重复标志。前面 handleAction 里那套if (state.tools.stick || state.wood < 10) return;就是标准写法。一句话总结这个坑:UI 的禁用只是通知,逻辑层的检查才是真相,任何时候都不要把安全边界寄托在界面状态上。

4.4 localStorage 在隐私模式写不进去

这个问题最容易在测试阶段漏掉。无痕模式下 localStorage.setItem 可能抛异常,或者写入后根本拿不到,导致存档静默失败。我在 save 外面包了 try/catch,失败时降级到内存存档,同时设置一个 storageOK 标志,在界面上显示“当前环境无法持久化存档”的小字提示。不做这层保护,用户玩到一半刷新,进度消失,大概率直接弃坑。很多东西看似是小概率事件,但对个人项目来说,一次报错窗口的体验就足以毁掉前面积累的全部好感。

5. 做完这个项目的一点个人体会

5.1 最大的收获

Caveman 做完以后,我最大的收获不是游戏玩法本身,而是重新理解了“最小可行系统”的分量。现代前端给了我们太多兜底,以至于很少有机会去问:如果没有这些,我还做不做得到?答案是可以。而且做完之后,你会反过来更尊重工具——它们替你做了本该你亲自做一遍的事情。以前总觉得写一个“真正的应用”必须上全家桶,现在我知道,把核心闭环想清楚,裸写也能撑起一个完整产品。

5.2 一个提升完成度的小技巧

最后分享一个实测很有用的小技巧:在游戏里加一个轻量日志函数,把关键动作写进一个数组,界面上滚动显示,玩家的“活着的感觉”立刻就有了。这段代码不超过三十行,但成品完成度至少提升两档。很多放置游戏看不起这个细节,恰恰是它让用户愿意多留十分钟。如果你也想复刻一个 Caveman,我建议从资源产出、升级、存档这个三角闭环开始,先别碰事件和特效,跑通基础闭环,剩下的全都可以慢慢加。等你在浏览器里双击打开自己写的 index.html,看着数值一点点涨起来,那种感觉比部署任何一个大应用都过瘾。