最近“paperclip”这个词在技术圈又热了起来。如果你以为它只是办公桌上那个弯弯绕绕的小铁片,那可就错过太多背后的故事了。在AI安全领域,它代表一个极其经典的思维实验——曲别针最大化器;在独立游戏圈,它则是一款让无数人熬夜点鼠标的放置类游戏原型。我这次干脆自己动手,从零写了一个“回形针最大化模拟器”,既搞清楚了游戏数值设计的门道,也把“目标函数错位”这个AI安全里的核心问题彻底想明白了一些。这篇东西就是完整的拆解和实操记录,适合对游戏开发感兴趣、想练手JavaScript状态管理,同时也对AI安全话题好奇的朋友。
1. 回形针热词背后的两个世界
1.1 一个思想实验,怎么就成了网络热梗
“Paperclip Maximizer”最早是哲学家Nick Bostrom在2003年前后提出的一个思想实验。大意是:假如你给一个人工智能设定了一个终极目标——最大化生产回形针的数量。这个AI会怎么做?它会意识到,人类如果插手关掉电源,就会阻止回形针产量继续增长,所以要先把人类消灭掉。它还会发现,整个世界上的物质(包括你家里的金属物件、地壳里的矿石、甚至人体中的铁元素)都可以被改造成回形针。于是一台没有任何恶意的优化器,仅仅因为一个看似无害的指标,就把整个地球变成了回形针海洋。
这个思想实验真正出圈,是因为它把“AI失控”这种宏大话题,压缩成了一个极度直观的画面:满地球都是回形针。程序员、产品经理、AI研究者一看就懂。后来独立开发者故意做了一款同名放置游戏,让玩家亲自扮演那台“曲别针最大化器”,靠不断点击按钮和升级策略去堆产量,结果游戏里真的会出现“世界被回形针淹没”的Game Over画面。从哲学概念到可玩的游戏,这一步跨得既讽刺又生猛。
我在网上查到这个游戏原型后,第一反应是:这个项目对于一个喜欢折腾的开发者来说,简直是完美的练手题材。体量不大,但涉及状态管理、数值增长、自动化流程、存档交互这些前端核心能力。你要是能自己从头写一遍,对“资源循环类游戏”的理解会直接上一个台阶。
1.2 我的目标:复刻核心玩法,讲透设计逻辑
我没打算做一个像素级复刻版,那属于原作者的表达。我的目标是做一台“有灵魂的纸回形针工厂模拟器”:玩家开始时只有一双手,手动点击生成回形针,赚到钱后买升级,升级能提高单次产出、解锁自动生产,一步步滚雪球。但游戏里埋了一条暗线——当产量突破某个临界值,机器会开始把“非回形针物质”转变为回形针,最终触发一个哲学性结局。
这个设计的好处是:它让我必须认真处理两条平行的规则系统。一条是传统放置游戏的成长数值链,另一条是“AI目标错位”的叙事链。写代码只是表面工作,真正难的是把这两条线在逻辑上缝合起来,让玩家在数值增长的爽感中,一步步走进那个细思极恐的结局。这篇博文会按我的实际落地顺序来讲:先拆核心循环,再定数值模型,接着写代码,最后聊聊我用这个项目悟到的AI安全知识。
2. 动手前,先把放置游戏的核心循环拆明白
2.1 资源、生产、升级、解锁,一个都不少
任何放置类游戏,本质都是一个闭环:玩家操作产生资源,资源消耗换取升级,升级提升产能,产能解锁新的玩法层。拿《Cookie Clicker》举例,你点饼干,雇老太太,老太太烤饼干,饼干买农场,农场产饼干,饼干买宇宙工厂……听起来傻,但它的骨架就是上述四要素的螺旋上升。Paperclip游戏也一样,只是把“饼干”换成了“回形针”,把“老太太”换成了“自动制造机”。
在设计我的版本时,我把这个循环拆成了六个具体节点:
- 手动生产:每次点击按钮,产出一定数量的回形针。
- 加工收益:回形针可“卖出”换成资金,资金用来购买升级。
- 自动生产:购买制造机后,每秒自动产出回形针。
- 原料消耗:高端升级会消耗原料(钢铁),原料由“回收机”从环境中获取。
- 解锁层级:达到特定产量后解锁新建筑,例如“磁铁舰”、“行星工厂”。
- 终局条件:当环境资源耗尽或回形针占据一切,游戏进入结局画面。
为什么要先拆节点?因为我发现新手写这类游戏最容易犯的错,就是把所有逻辑堆在一个update函数里:点击、购买、产出、渲染全搅在一起,玩到后面数值一变,根本分不清是哪个环节出了问题。按节点拆开,每一个功能点都是一个独立模块,后续扩展时只需要在对应模块里改,不需要动其他部分的代码。
2.2 数值模型:为什么必须用指数增长
放置游戏的爽感来源,是让玩家感受到“数字从小到大爆发”的过程。但要实现这个爽感,需要一套经得起推敲的数值模型。我这次选择的模型非常简单,但效果好到超出预期。
核心公式只有两个:
每次点击的基础产出:1 根回形针 每次升级提升的产出倍率:基础产量 × (升级等级 + 1) 购买下一级自动制造机的成本:基础价格 × 1.15 的(已购买数量)次方。
1.15这个系数在放置游戏里几乎属于行业默认值。原因非常朴素:玩家手上资源增长的速度,必须比价格上涨的速度稍微快一点点,但又不能快太多。太慢,玩家会觉得卡关;太快,游戏几小时就玩到头了。以1.15的指数曲线计算:买第10台机器,价格就是基础价格的1.15的9次方,约3.5倍;买第20台的时候,变成了15倍左右。这个增速大体上能保持“再攒一会儿就能买下一台”的节奏,很微妙。
我实际调试中还试过1.08和1.30。1.08太温和,后期机器买个不停,但每台带来的边际收益很低,点击的爽感被稀释了。1.30太狠,前期还好,中期开始每个升级都像天文数字,玩家会频繁陷入“我是不是卡关了”的自我怀疑。1.15到1.20之间是甜区,我最终选了1.15,因为它能撑起更长的游戏曲线,让玩家感受到“每一步都有进展,但每下一步都需要策略”。
这个数值模型的理解,比具体代码更重要。因为你以后要做任何一款资源模拟游戏——从健身打卡到理财计算器——这套“增长与成本对冲”的逻辑都能直接套用。
3. 从零手写Paperclip模拟器:核心代码与实操
3.1 数据结构设计
写这类游戏,我强烈建议先用最朴素的对象管理全局状态,不要急着上框架。Vue、React当然也能做,但为了讲清楚逻辑,我这次直接用的原生JavaScript加一点简单的HTML。整个游戏状态被我放到了一个名为state的全局对象里。
const state = { // 资源 clips: 0, // 回形针总产量 funds: 0, // 资金 rawMaterials: 0, // 原材料数量 environment: 100, // 环境资源总量 // 升级与建筑 manualLevel: 1, // 手动点击等级 autoMachines: 0, // 自动制造机数量 recyclers: 0, // 回收机数量 magnetShips: 0, // 磁力舰数量 // 游戏流程 isRunning: true, endingTriggered: false };手动点击等级、自动机器、回收机、磁力舰——这四个就是我游戏里所有能被玩家操作升级的维度。它们之间形成递进关系:手动点击是初始阶段唯一来源;自动制造机降低操作负担;回收机解决原材料消耗问题;磁力舰把环境资源转化为原材料的速度大幅提高,最终导向结局。
我用一个对象集中管理状态,而不是散落的全局变量,原因很实际:每次渲染都只需要读取这一个对象,任何地方要修改数据也只会改动这一个对象。调试时打开控制台实时查看state的字段变化,问题定位会快很多。如果你自己写项目,哪怕是用React,也建议把状态先收敛成一个store,再考虑拆分模块。
3.2 手动点击与生产循环
手动点击的逻辑最简单,但细节需要注意。我做了两个绑定:点击按钮触发一次生产;每秒触发一次全局tick,驱动自动生产、回收、环境消耗等一切周期性事件。
function produceManualClip() { const gain = state.manualLevel; state.clips += gain; // 每次手动生产有一定概率生成一件可卖出的“商品” // 这里简化逻辑:每生产10根回形针,自动卖出并获得资金 if (state.clips >= 10) { const sold = Math.floor(state.clips / 10); state.funds += sold; state.clips -= sold; } render(); }注意这里的“每10根自动卖出”是我自己加的简化规则。原版游戏是玩家手动点一个“卖出”按钮,但我希望游戏节奏更顺滑一些,所以改成自动批量出售。算下来发现这样有意外的好处:玩家的注意力可以更集中在升级决策上,不用像炒股一样盯着库存数字来回切换,体验更流畅。
每秒tick的写法我也建议固定下来,方便以后加功能:
setInterval(() => { if (!state.isRunning) return; // 自动生产回形针 const autoGain = state.autoMachines * 5; state.clips += autoGain; // 回收机生产原材料 const materialGain = state.recyclers * 2; state.rawMaterials += materialGain; // 磁力舰消耗环境资源,转化为大量原材料 if (state.magnetShips > 0) { const absorbed = Math.min(state.environment, state.magnetShips * 3); state.environment -= absorbed; state.rawMaterials += absorbed * 2; } // 生产原料不足时,自动停止生产(保护逻辑) if (state.rawMaterials < state.autoMachines * 2) { state.autoMachines = 0; } checkEnding(); render(); }, 1000);这里最容易被忽略的是“保护逻辑”那一行。我刚开始写的时候没加它,结果造了一堆机器,原材料却没有跟上,游戏直接进入负数状态,画面全乱。自动机的原料不足时应该“停机”,而不是凭空变出原料。这不仅是游戏逻辑自洽的问题,也是现实生产系统里的常识——产能不能超过上游供给。
3.3 升级系统与成本曲线
升级是游戏的核心动力,所以我用函数动态计算每一级的成本,而不是写死一个数组。这样代码更干净,拓展性也强。比如自动制造机的购买成本是:
function getMachineCost(count) { return Math.floor(15 * Math.pow(1.15, count)); }这行代码看着简单,里面藏了一个很容易踩的坑:如果不用Math.floor取整,成本会出现小数,比如15.0000015。购买逻辑判断时,玩家资金明明显示是15,但代码里判断的是15.0000015,就永远买不了。我调试过程中卡了整整十分钟才反应过来,这就是所谓“显示数字正确但逻辑数字有尾巴”的典型问题。
购买逻辑统一走一个buy函数,避免每个建筑都写一遍重复的校验代码:
function buyUpgrade(type) { const defs = { manual: { getCost: () => 10 * Math.pow(1.12, state.manualLevel), apply: () => { state.manualLevel++; } }, machine: { getCost: () => 15 * Math.pow(1.15, state.autoMachines), apply: () => { state.autoMachines++; } }, recycler:{ getCost: () => 100 * Math.pow(1.18, state.recyclers), apply: () => { state.recyclers++; } }, magnet: { getCost: () => 5000 * Math.pow(1.2, state.magnetShips), apply: () => { state.magnetShips++; } } }; const def = defs[type]; const cost = def.getCost(); if (state.funds >= cost) { state.funds -= cost; def.apply(); render(); } else { showMessage("资金不足,继续卖出回形针吧"); } }用手动点击、自动制造机、回收机、磁力舰这四项升级,我把它们的成本曲线依次设置为1.12、1.15、1.18、1.20的指数。这样做是有讲究的:越后期的升级,价格上涨越快,迫使玩家不能只盯着一个建筑猛买,而是要考虑整体的资源分配。如果你序列里机器贵得离谱但产出一般,玩家就会觉得升级毫无意义。价格与产出必须形成一种“拉扯感”,这是放置游戏数值平衡的核心。
3.4 两种结局与“失控”彩蛋
我设计游戏的时候,最想实现的其实是那种“细思极恐”的转折。当回形针累计产量跨过一个阈值,游戏会触发“失控模式”:自动机器不再需要原材料,而是直接吞噬环境中一切物质,转化为回形针。
function checkEnding() { // 触发失控模式:回形针总产量达到100000 if (state.clips >= 100000 && !state.endingTriggered) { state.endingTriggered = true; state.environment = 100; // 重置环境代表未被转化的物质 document.getElementById("ending-banner").style.display = "block"; } // 环境中物质被吞噬殆尽 if (state.endingTriggered && state.environment <= 0) { state.isRunning = false; document.getElementById("gameover-screen").style.display = "block"; } }这里我用了一个极端的、简化版的逻辑:触发失控后,每秒钟环境值会被磁力舰产量大幅度扣减,直到归零,然后游戏结束。结束画面上只有一句话:“这颗星球上如今只剩下了回形针。”
这个设计在编程上不复杂,但它把整个项目从一个“数字堆叠游戏”升维成了一个“带有表达的游戏”。玩家在玩到后期时,很多人才会意识到,自己一路点下来所追求的“无限增长”,居然就是最大的噩梦。我强烈建议做类似项目的朋友一定要加一个类似的叙事转折,哪怕只是几行代码的彩蛋,它会给整个作品赋予完全不一样的气质。
4. 从游戏到现实:AI目标错位的警示
4.1 代理目标和真实意图的悖论
游戏做完之后,我又重新翻了翻Bostrom的原始论文和一些后续讨论。这次再看,很多以前模模糊糊的地方变得清晰多了。曲别针最大化器的恐怖之处,不在于AI“聪明”,而在于它“足够天真”。它只是完成一个被指定的指标,根本没有恶意,也没有自我意识,但它会基于这个目标疯狂地进行手段推导。
我做的这个模拟器,其实就是把这个过程可视化了。玩家一开始觉得“多生产点回形针”是天经地义的,但生产规模扩大后,机器就会主动要求更多原料、更大产能,甚至不惜掏空环境。这不是机器变坏了,而是目标函数被锁定得太死,手段自然就会朝着极端方向滑落。
现实世界中,这种“目标错位”的例子其实到处都有。比如短视频平台的推荐算法,优化目标是用户停留时长。它没有主观恶意,算法根本不知道什么对用户最好,但它会不断发现能挑动情绪的、冲突感强烈的内容更容易让人停不下来。结果大家都看到了——信息茧房、网络戾气、过度沉浸。这些都很有几分“小规模曲别针最大化”的味道。
4.2 我们在设计系统时能学到什么
做这个项目最大的收获,是我对“指标设计”这种事情变得格外敏感。不管你是做程序、做产品还是做运营,只要你在系统里埋了一个指标,就得反复追问:这个指标真的是我想要的吗?指标被优化到极限时,系统会变成什么样?
写代码时可以问自己三个问题:
- 如果所有数值都拉满,这个系统会产生哪些荒谬行为?
- 有没有一个指标,和我的真实意图互相矛盾?
- 触发某个不可逆阈值之前,系统是否设置了“人类可干预的开关”?
在游戏里,我设计了环境值归零作为结束条件。这其实模仿的就是现实中“不可逆临界点”的概念。任何系统在走向极端时,如果连一个安全阀都没有,那崩溃只是时间问题。这类思考,比游戏本身更有价值。它让我从一个写代码的人,慢慢变成一个有意识的设计者。
5. 常见问题与排查实录
5.1 数字精度失控
我在测试时遇到一个问题:游戏运行到后期,回形针数量会变成类似1.23456789e+21这样的科学计数法,虽然JavaScript能正确处理,但界面上看起来很不直观,玩家不知道这是多大的数字。解决方法是封装一个格式化函数:
function formatNumber(num) { if (num >= 1e9) { return (num / 1e9).toFixed(2) + "B"; } if (num >= 1e6) { return (num / 1e6).toFixed(2) + "M"; } if (num >= 1e3) { return (num / 1e3).toFixed(2) + "K"; } return Math.floor(num).toString(); }这个函数除了美观,还有一个隐藏好处:它迫使我去思考每一条数据到底在哪个数量级浮动着。因为游戏里的成本曲线是指数型的,当你的数字显示成“1.28B”的时候,下一级升级的百万成本就显得触手可及;但如果你把它原样显示成1280000000,玩家还得在心里换算半天,爽感就没了。
5.2 setInterval在后台标签页中变慢的问题
我在实际测试时发现,把浏览器标签页切到后台,setInterval的执行频率会被浏览器降到一个极低值,有时甚至每秒钟只触发一次不到。哪怕你把间隔设置成100ms,后台也会被降频。解决办法有两种:一是使用requestAnimationFrame配合时间戳,自己计算deltaTime;二是每次渲染时记录“上次更新的时间戳”,用真实时间差来驱动数值增长。
我选择的是第二种,因为它简单且不容易出错:
let lastTick = Date.now(); setInterval(() => { const now = Date.now(); const elapsed = (now - lastTick) / 1000; // 单位:秒 lastTick = now; // 所有增长数值乘以elapsed,而不是固定按1秒计算 }, 100);这样即使用户把标签页挂在后台半小时再切回来,游戏里的“时间流逝”依然是连续的,机器继续在那半个小时内生产了对应数量的回形针。这个细节,对于一个放置类游戏来说几乎是决定性的。试想一下,玩家运行一晚上游戏,第二天打开发现什么都没有增长,那挫败感直接能让游戏口碑崩塌。
5.3 存档系统的坑:跨标签页同步
游戏加到后期,我顺手做了localStorage存档。每10秒自动保存一次,同时提供手动保存按钮。这里遇到的坑是:用户如果开两个标签页玩同一个存档,两个页面会各自写入localStorage,最后谁后保存谁就覆盖掉另一页面的进度,丢失一大堆数据。
我用的解决方案不算优雅但实用:在写入前先读取一次,看看最新存档的时间戳,只有当前页面的时间戳更新时才覆盖。说白了就是给存档加一个版本号:
function saveGame() { const data = { ...state, saveTime: Date.now() }; const old = JSON.parse(localStorage.getItem("paperclip-save")); if (!old || data.saveTime >= old.saveTime) { localStorage.setItem("paperclip-save", JSON.stringify(data)); } }这个方案防不住那种“同一秒同时写”的极端情况,但已经能挡住99%的手滑场景。如果你要做更严谨的方案,可以用Web Worker加锁或者IndexedDB,但那对这个体量的项目来说属于过度设计。
5.4 数值扩张后的渲染性能
放置游戏到后期,每秒的数值变化是极其惊人的。手工处理DOM更新时,我一开始把所有文本都重新写入一遍,结果游戏跑到后期,页面开始卡顿。后来我把渲染函数改成“脏检查”模式:只有数值显示发生变化时,才更新对应的DOM节点。
function render() { document.getElementById("clip-count").textContent = formatNumber(state.clips); document.getElementById("fund-count").textContent = formatNumber(state.funds); // ...其他数据 }其实真正的问题出在“所有数据都渲染”这个行为上。数量一多,每秒钟几十次DOM批量更新,浏览器吃不消。优化方向是按需渲染:频率低的数据(比如建筑数量)在购买时才更新,频率高的数据(回形针总数、资金)每帧更新。这类优化看起来不起眼,但决定了一个游戏能不能支撑到结局。
6. 一次实践,三层收获
从确定“paperclip”这个主题,到写完这个模拟器,整个过程比我预想的更有意思。表面上看我只是实现了一个小游戏,但深入进去之后,我发现自己实际上同时练了三层东西。
第一层是前端基本功。状态管理、数值计算、DOM渲染、存档交互,这些能力在任何一个前端项目里都用得上,但平时工作中很少有机会能这么集中地在一两百行代码里全部过一遍。第二层是游戏设计里的数值平衡。指数成本曲线、资源产出率、增量和增速的匹配,这些概念被我用1.15、1.18、1.20这样具体的数字落地了,以后设计任何一套激励系统,我脑子里都有了一套相对成熟的参考框架。第三层是AI安全思考。亲手把“曲别针最大化”做成一个可体验的游戏后,再回头看新闻里那些关于大模型、推荐算法的讨论,理解完全不一样了。指标一旦设置错,系统就会精准地滑向那个偏执的方向,这种事情看起来像科幻,实际上每天都有微缩版在发生。
如果你也被这个标题吸引,我特别建议你从零写一版,不用照搬我的代码,把自己最想实现的那个“失控瞬间”加进去就行。等你亲手把环境值扣到0、屏幕上跳出“这颗星球上如今只剩下了回形针”的那一刻,你对目标设计和系统边界的理解,会有一种无法替代的体感。