不用框架也能做游戏:原生JavaScript手写放置游戏Caveman全记录
发布时间:2026/10/28 3:54:48 锦皓数字建站

前阵子动手做了个小项目名字叫 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/s0.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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCaveman - 原始人生存日志/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 idapp h1Caveman 原始人洞穴/h1 section idresources span食物 b idfood30/b/span span木材 b idwood0/b/span span石头 b idstone0/b/span span生命 b idhp10/b/span span第 b idday1/b 天/span /section section idactions 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 存档与离线收益关掉浏览器之后怎么办放置游戏最迷人的时刻之一是关掉页面几个小时再打开看到资源涨了一大截。要做到这一点必须有可靠的存档和离线结算。存档我选了 localStoragekey 命名为 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看着数值一点点涨起来那种感觉比部署任何一个大应用都过瘾。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。