简介这是一款由英国《金融时报》制作的叙事性新闻游戏源码以优步司机一周赚取1000美元的真实任务为主线将零工经济议题转化为可交互的驾驶决策体验。项目采用JavaScript开发包含完整的客户端与服务端实现适合新闻媒体、交互叙事开发者以及想用代码讲述社会议题的前端工程师研读。压缩包共78个文件、约517KB核心为28个JS逻辑文件、11个HTML页面与9个JSON数据文件同时配有SCSS样式、TypeScript类型、YAML配置及Dockerfile可快速本地构建并部署。已有300人查看学习。资源附有清晰的项目说明、依赖安装与启动命令、基于CI的分支审查部署流程并区分MIT软件许可与FT内容版权便于合规使用。整体体量精简但叙事结构、数据驱动决策与部署链路完整是研究互动新闻游戏与零工经济报道的实用范例。1. 从新闻到游戏uber-driver-game 要把司机的账本变成一条可以重复走的时间线叙事性新闻游戏看起来像游戏本质却是一条带数据的调查报道。uber-driver-game 这个方向要做的不是模拟开网约车的爽感而是让玩家在几次点击之间体会到优步司机在平台抽成、空驶里程、深夜疲劳和不确定性收入之间怎么挣扎。现实里记者跑一个月采到的一摞小票在游戏里就是三个小时的操作接单、等客、扣费、扣折旧、再等下一单。适合做这件事的人是新闻编辑室里的互动开发者、想讲清平台经济的数据记者以及准备做严肃议题但不想写成说明文的独立游戏作者。与其把结论拍在读者脸上不如让读者自己跑一遍深夜出车看到余额和疲劳值同时上升追问一句这钱到底被谁赚走了2. 选型用 Ink.js 还是 JSON 状态机做叙事新闻游戏做叙事游戏第一件事不是写文案而是决定叙事状态由谁管理。这个决定会直接影响后面加分支、调数值、出多结局的成本。常见的方案有三个Ink.js、Twine、以及把故事写成 JSON 状态机自己在 React 里跑。很多团队一看 Twine 上手快就直接用了结果做到一半发现分支多了根本没法维护也有团队硬用 React 写 if/else 堆剧情最后代码成了没人敢动的黑匣子。我一般会先问一个问题这个故事的核心变量是数值还是对话如果数值决定命运那就别把叙事状态放太散。2.1 Ink 的核心抽象knot、divert、choice 和外部函数Ink 是 inkle 工作室开源的叙事脚本语言它的设计目标就是把故事写成可编程文档。一段 .ink 文件里定义场景节点knot-是跳转divert [选项]是玩家选择VAR声明全局变量~做赋值和运算。最妙的是它支持外部函数Ink 脚本里可以调用 JavaScript 函数来算数、取时间、请求后端数据。对于新闻游戏来说这意味着你可以在 Ink 里写故事分支把收入计算、税收、折旧这些事丢给 JS 做两边都不越界。下面是一段最基础的 Ink 脚本骨架展示节点和选择是怎么组织的一晚上出车流程VAR money 0 VAR fatigue 0 - start start 现在是晚上七点你刚洗完车坐进驾驶座手机屏幕亮着。 [开始接单] - accept accept 平台推来一单3.8 公里乘客位置在商场西门。 你看到一个 50 分钟跑 19 元的预估心里算了一下大概不到 5 块钱的利润。 [接单] - drive [拒绝] - idle drive 乘客上车后一直刷短视频导航声音被盖住你绕了一个小弯。 这段路程实际跑了 5.2 公里耗时 18 分钟。 [查看结算] - settle settle 这笔订单最终结到了 22.4 元。 系统扣掉平台服务费和信息费之后你到手 17.3 元。 [继续接单] - accept这段脚本里没有写死最终余额因为真正的费率和抽成要放到外部函数里算。Ink 只负责讲故事流程告诉玩家“发生了哪一笔订单”至于具体进账多少由外部函数结合里程和时长计算后写回变量。这分离方式保证了同一个故事脚本可以在不同城市、不同计价规则下复用。2.2 React 与 Ink 的结合方式状态快照和历史记录Ink.js 是官方提供的 JavaScript 运行时浏览器里直接npm install inkjs就能用。加载 JSON 编译产物后用story.Continue()推进文本story.currentChoices拿到当前选择列表选完用story.ChooseChoiceIndex(i)进入对应分支。典型 React 组件的循环是这样的import { Story } from inkjs; import storyJSON from ./story.json; const story new Story(storyJSON); function GameScreen() { const [paragraph, setParagraph] useState(); const [choices, setChoices] useState([]); const [money, setMoney] useState(0); const [fatigue, setFatigue] useState(0); const [history, setHistory] useState([]); function refresh() { let text ; while (story.canContinue) { text story.Continue(); } setParagraph(text); setChoices(story.currentChoices.map(c c.text)); setMoney(story.variablesState.Get(money)); setFatigue(story.variablesState.Get(fatigue)); setHistory(prev [ ...prev, { money: story.variablesState.Get(money), fatigue: story.variablesState.Get(fatigue), scene: text } ]); } function choose(i) { story.ChooseChoiceIndex(i); refresh(); } useEffect(() { refresh(); }, []); // 渲染文本、按钮、当前余额和疲劳度 return ( div classNamegame-screen p classNamenarrative{paragraph}/p div classNamechoices {choices.map((text, i) ( button key{i} onClick{() choose(i)}{text}/button ))} /div div classNamestats现金 {money} 元 | 疲劳 {fatigue}/div /div ); }story.variablesState.Get()是读取 Ink 全局变量最可靠的方式refresh()在每次推进后统一读取天然形成一条历史快照。这个 history 数组后面做多结局回放和折线图都是现成素材。要注意的是Continue()会把一段连续文本一次性返回不要用一个段落一个段落去猜叙事节奏交给 Ink 的标签系统去分段会更省事。2.3 选型边界什么场景该用 Twine、RenPy 或纯代码Ink 不是唯一的选择。不做数值模拟的纯文字冒险Twine 更合适它在浏览器里编辑和发布变量也能用但分支一旦复杂起来编辑器就变成一张蜘蛛网版本对比也很痛苦。RenPy 适合视觉小说有现成的立绘、背景、音效体系但如果你追求页面像新闻专题那样长文滚动它就不太对口。纯 JSON 状态机的好处是零依赖、全家人都会看缺点是每加一个分支节点都要手写路由和文本拼接维护成本极高。方案叙事表达力数值模拟移动端适配适合场景Ink.js React强脚本即文档强外部函数全局变量好纯前端新闻游戏、数据故事Twine强可视化分支弱变量查着麻烦一般导出 HTML 较大短篇叙事、原型RenPy视觉表现强一般Python 可用弱主要走桌面/APK视觉小说、角色扮演JSON 状态机弱需要自造工具中逻辑在 JS好团队熟悉 JS 的小项目我选择 Ink.js 的核心原因只有一个它把“剧情结构”和“数值运算”严格分开。新闻游戏里数字一旦变了故事会跟着变这两样东西必须都放在明面上谁改动了什么一眼就能看出来。Twine 的变量藏在节点角落JSON 状态机把场景散在各处都属于改一个数要翻大半天的状况。3. 建模司机账本把优步司机的成本拆到每个订单里游戏可以虚构人物但数字不能造假。优步司机经济模型的最小计算单元是一单一单收入减去一单成本累加起来才是三小时班次的净收入。很多人做这类游戏翻车的第一个原因就是只算了收入没算成本结果玩家跑一晚上发现时薪高得离谱真实感全没了。成本不是一个百分比而是由平台抽成、油费、车辆折旧、固定支出四个部分叠加出来的。把它们都塞进一个外部函数让每笔订单都走同一套计算比在故事脚本里写死数字靠谱得多。3.1 成本模型抽成、空驶率、折旧这几个数不能拍脑袋我先说一组测试用的初始参数你可以在产品里根据城市做调整。平台抽成按 25% 计信息费等杂费再扣 2 元每单油费按 0.5 元/公里车辆折旧按 0.3 元/公里这是新能源车常见综合值保险和维修摊到每公里 0.2 元。这样一公里的直接成本就是 1 元。如果平均每单收入 18 元、行驶 8 公里成本是 8 元到手后还要减去平台扣款最后净赚大概 4.5 元。这还没算空驶里程——为了接下这一单你往往要空跑 2 公里这 2 公里的油费和时间也是成本。const COST_CONFIG { platformFeeRate: 0.25, miscFeePerOrder: 2, fuelCostPerKm: 0.5, depreciationPerKm: 0.3, maintenancePerKm: 0.2, emptyMileRate: 0.25, basePay: 6 }; function calcOrderNet(distanceKm, durationMin, surge 1) { const grossFare basePay distanceKm * 1.8 durationMin * 0.35; const surgeFare grossFare * surge; const platformFee surgeFare * COST_CONFIG.platformFeeRate COST_CONFIG.miscFeePerOrder; const directCost distanceKm * (COST_CONFIG.fuelCostPerKm COST_CONFIG.depreciationPerKm COST_CONFIG.maintenancePerKm); const emptyKm distanceKm * COST_CONFIG.emptyMileRate; const emptyCost emptyKm * COST_CONFIG.fuelCostPerKm; return { grossFare: round2(surgeFare), takeHome: round2(surgeFare - platformFee - directCost - emptyCost), costBreakdown: { platformFee: round2(platformFee), directCost: round2(directCost), emptyCost: round2(emptyCost) } }; }这段代码的作用是把“一单赚多少钱”变成一个可解释的函数。grossFare是乘客支付的车费surge是高峰溢价倍数platformFee包含平台抽成和杂费emptyKm用平均空驶率折算出看不到的消耗。takeHome才是司机真正落袋的钱。你会发现大多数时候每单到手只有乘客费用的 50% 左右这是游戏里第一个要传达给玩家的数字。3.2 状态设计疲劳值、现金余额、接单意愿怎么联动单笔账算清楚后整个班次的压力来自状态联动。我把游戏状态分为三个现金余额、疲劳值、待机耐心。疲劳不是简单的惩罚它会影响玩家可见的选项质量。比如疲劳值超过 7 之后系统多给一个“喝口热水再走”的选项选择它要花 15 分钟但能防止后续出现误操作如果硬撑下一个订单界面会突然弹出一个“确认到达”按钮玩家手滑点错就要绕回去。这就是把现实中的疲劳表现翻译成游戏机制。VAR money 0 VAR fatigue 0 after_order ~ fatigue fatigue 2 ~ money money CalcFare(4.2, 1.0) 你现在的余额已经是 {money} 元但腰开始发酸。 [继续接单] - accept_order [回家休息] - end_shift这里不要用特别复杂的公式去算疲劳二十分钟一单跑六单疲劳就逼近门槛太简单的叠加会让玩家第三单就崩溃。真实司机夜晚连续干三四个小时才会明显疲惫所以我会把疲劳增量设置为 1.2 到 1.5还要加上随机波动让玩家感觉到状态不受完全控制。疲劳的核心价值是给玩家一个选择继续挣钱还是保身体。这个两难就是“生活”这两个字的体验化。3.3 把新闻数据拆成事件卡高峰溢价、乘客取消、交通罚单新闻游戏和普通模拟经营最大的区别是它需要由事件传递信息。每一个事件背后最好能对应一条公开报道的数据。比如“乘客取消订单”这个事件对应现实中 10% 到 15% 的订单会在司机到达后被取消处理方式不是让玩家直接上班里数据而是在取消后显示一句“没有补偿你开到这里的 4 公里油耗从余额里默默消失了”。事件不要设计成全随机。我建议把事件写成一个数组每张卡包含触发条件和两个以上的选择分支分支会按概率加权。这样既保留了随机性又可以做到在某个时段必现某个主题。下面是一个事件卡的数据定义在 JS 里管理Ink 只负责引用触发后的文案const EVENT_CARDS [ { id: cancel_order, trigger: { fatigueMin: 3 }, weight: 30, title: 乘客取消订单, description: 系统弹出一条消息“乘客已取消订单无费用补偿。”, choices: [ { text: 开回原地等下一单, effect: { fatigue: 1, emptyKm: 2.4 } }, { text: 追评该乘客, effect: { fatigue: 2, money: -3 } } ] }, { id: surge_alert, trigger: { timeRange: [21, 23] }, weight: 15, title: 高峰期溢价, description: 机场方向出现 1.8 倍预估价。, choices: [ { text: 空驶 6 公里杀过去, effect: { money: 12, fatigue: 1 } }, { text: 继续在市中心扫街, effect: { fatigue: 1 } } ] } ];事件卡抽象让每一条数据都能被当成一条新闻线索来打磨。玩家每次抽到事件卡都是一次“读新闻”的过程而不是遇见机械的随机惩罚。这个卡片机制还有一个好处数据记者可以直接往 JSON 里添卡片不用碰任何 React 代码新闻编辑室里非技术协作也能跑顺。4. 跑通最小流程接单、行驶、结算的一次完整实现前面把理论和数据结构说完现在动手做一个能跑的最小版本。这一章的代码可以直接抄进一个 Vite React 项目里核心流程是初始化 Ink 引擎 → 绑定计价外部函数 → 渲染文本和选项 → 结算并记录历史。为了让代码尽量能直接运行我把 story.json 相关的编译步骤也写在里但不会依赖特定版本的包你只要按 npm 安装最新版本即可。4.1 初始化项目和引入依赖常见做法是用 Vite 创建 React 项目然后安装 inkjs。你不需要额外装状态管理库因为 Ink 本身已经是叙事状态容器React 只负责渲染快照。要用的依赖只有两个。npm create vitelatest uber-driver-game -- --template react cd uber-driver-game npm install inkjs接着准备故事源文件。你可以把.ink文件放在src/story/story.ink用 inklecate 工具编译成 JSON也可以直接用 inkjs 自带的 Compiler 模块在构建时编译。这里我推荐预编译成 JSON找一个稳定编译器把.ink变成story.json放到public/下运行时只需要fetch一次不增加首屏打包体积。如果你不确定工具细节先走官方例子等脚本做得足够稳再换预编译。4.2 写第一个 .ink 脚本接单、路径、结算现在写一个能表达一晚上的完整循环故事。这段脚本用到了外部函数CalcFare它在本节后面会由 JavaScript 绑定。脚本里的变量money和fatigue是全局变量外部函数只返回数字不改状态状态全部交给 Ink 自己做这样主循环很清晰。EXTERNAL CalcFare(distance, surge) VAR money 0 VAR fatigue 0 - start start 晚上八点你开始今晚的第一单。 [上线接单] - accept accept 平台推来一单4.2 公里乘客在小区北门。 [接单] - drive [嫌距离短拒掉] - idle_wait idle_wait 你等了两分钟手机没有任何新订单。 ~ fatigue fatigue 0.5 [继续等] - accept [下线结束] - end_shift drive 乘客定位不准你绕到东门才发现人站在马路对面。 实际跑了 4.8 公里用时 26 分钟。 ~ fatigue fatigue 1 ~ money money CalcFare(4.8, 1.0) [查看结算] - settle settle 这笔订单到账 {money} 元。 你的疲劳值为 {fatigue}。 [继续接单] - accept [结束今晚] - end_shift end_shift 今晚到这里。你最终带走了 {money} 元。这段脚本里没有分号语法靠换行和缩进组织。EXTERNAL声明了CalcFare接收两个参数~ money money CalcFare(...)把返回值写进全局变量。{money}是 Ink 的文本插值渲染时会被替换成实际数字。.ink脚本最大的优势就是编辑文本时不需要碰组件代码一台电脑配好编译环境叙事可以一直在同一个文件里改。4.3 React 渲染层解析 Ink 文本和选择JSON 编译好之后React 端只需要维护一个游戏组件的状态机。下面代码实现了从 fetch JSON 到选项渲染的完整闭环关键点是在refresh里用while (story.canContinue)拉取全部文本避免一句一句往外蹦同时通过BindExternalFunction把计价函数交给 Ink 调用。绑定外部函数必须在第一次Continue()之前完成。import { useEffect, useState } from react; import { Story } from inkjs; import { calcOrderNet } from ./economics; let story null; async function loadStory() { const res await fetch(${import.meta.env.BASE_URL}story.json); const json await res.json(); story new Story(json); story.BindExternalFunction(CalcFare, (distance, surge) { const result calcOrderNet(distance, 30, surge); return result.takeHome; }); } function Game() { const [text, setText] useState(); const [choices, setChoices] useState([]); const [money, setMoney] useState(0); const [fatigue, setFatigue] useState(0); useEffect(() { loadStory().then(() refresh()); }, []); function refresh() { let output ; while (story.canContinue) { output story.Continue(); } setText(output); setChoices(story.currentChoices.map(c c.text)); setMoney(story.variablesState.Get(money)); setFatigue(story.variablesState.Get(fatigue)); } function choose(i) { story.ChooseChoiceIndex(i); refresh(); } return ( div classNamegame-wrap div classNamestats-bar span现金{money.toFixed(2)} 元/span span疲劳{fatigue.toFixed(1)}/span /div p classNamenarrative{text}/p div classNamechoices {choices.map((t, i) ( button key{i} onClick{() choose(i)}{t}/button ))} /div /div ); }calcOrderNet来自上一章的经济模块这样 Ink 脚本里只需要知道乘车距离和溢价倍数不需要理解成本拆分。loadStory用 async/await 保证绑定顺序否则你会遇到“外部函数还没定义就被调用”的报错那算是新手最常见的第一个坑。执行到这步玩家点一下“接单”就能看到余额变化整个链已经通了。4.4 数值更新逻辑把结算拆成可见的明细为了让玩家明白钱去哪了每次结算不能只改一个 total。我一般会在calcOrderNet里返回一个明细对象然后在 UI 里用一个小表格展示。Ink 只负责把叙事文本写清楚明细展示由 React 在refresh时读取历史数组完成。const orderHistory []; function settleOrder(distance, surge) { const detail calcOrderNet(distance, 30, surge); const prevMoney story.variablesState.Get(money); const nextMoney prevMoney detail.takeHome; orderHistory.push({ distance, surge, gross: detail.grossFare, fee: detail.costBreakdown.platformFee, cost: detail.costBreakdown.directCost detail.costBreakdown.emptyCost, takeHome: detail.takeHome, balance: nextMoney }); return detail.takeHome; }这个数组不参与游戏判断只做展示和复盘最后可以直接导出 CSV 给记者核对。很多新闻游戏最后上线时会被编辑部要求“你的数据到底能不能跑出表来”有这份明细就能证明游戏里的每一笔钱都来自同一套财务模型不是一个写着玩的随机数。该绑定到外部函数的地方就绑定settleOrder这样叙事节点settle一执行明细自动落库。5. 避坑经济模拟最容易翻车的 5 个地方这套方案搭起来不难难的是让数字和叙事配合得严丝合缝。下面五条是我在这类项目里踩过、也帮别人救过的坑每一条都按“现象 → 原因 → 解决”写清楚。你不需要全背下来但到了测试阶段回头看一眼能少熬几个通宵。5.1 叙事文本说的数字和实际入账不一致现象故事里写“你赚了 18 元”结算界面却显示入账 13.5 元。原因重复调用了外部函数或者文案在计算前就写死了数字。Ink 的可读性让人放松警惕随手在文本里打了一个总额后续计价参数一调整就对不上。解决所有数字一律用变量插值不让手写数字出现在叙事文本里。建立一条规则——文案里看到的任何金额都只能来自story.variablesState.Get(money)或明细返回结果禁止在.ink里直接写 18、22 这种字面量。5.2 随机事件让测试结果不可复现现象同一份故事脚本跑十次十个结局样本数据没法验证经济模型。原因Ink 内置的随机函数没有种子每跑一遍都重新摇。我把随机决策全部挪到 React 侧用一个带 seed 的伪随机函数生成本次班次的“事件骰子”再告诉 Ink 走哪条分支。这样测试时固定 seed回归时结果完全一致。用户端上线再放开 seed不影响体验。function seededRandom(seed) { let t seed % 2147483647; if (t 0) t 2147483646; return function () { t t * 16807 % 2147483647; return (t - 1) / 2147483646; }; }5.3 经济模型抄别人的没算空驶率现象游戏里时薪高得离谱玩家三小时赚了三百多编辑部觉得假。原因参数直接搬了网上的平均时薪没有把空驶里程和平台抽成叠进每一单。空驶率是优步司机最隐秘的成本20% 的空驶意味着每晚白白烧掉一小时油费。解决单独维护一个emptyMileRate在每个订单计算里先按距离折算空驶里程并展示在结算明细里。玩家第一次看到“空驶 2.4 公里”这个条目自然会对现实司机多一层理解。5.4 分支爆炸导致剧情维护失控现象写到第 20 个节点时Ink 文件里到处是箭头加一个新事件要改五个跳转。原因把所有可能性都做成独立节点而且节点之间互相串联。解决把事件卡片化每张卡片是一个独立的 sub-knot 或子文件卡片内部只关心自己的选择和结果不和全局剧情强行握手。全局叙事只提供主干框架事件由事件系统注入。这样每张卡最多五六个节点整体复杂度是线性增长而不是指数增长。5.5 移动端长按按钮误触和疲劳值背锅现象玩家反复抱怨“我没想回家休息手滑点了”。原因选项按钮太小且疲劳值高了之后界面正中央弹接单按钮容易误触。解决把所有选择按钮高度做到 44px 以上疲劳值超过阈值后危险选项比如闯红灯、超速接客改成二级确认先弹一个“确定要这么做吗”再进入分支。这件事看起来是交互问题其实直接影响经济模型误触一次代价 20 元玩家会把它计入游戏难度里不能当作随机惩罚。6. 让玩家看见数字数据回放与多结局验证游戏跑通以后最容易被忽视的是复盘能力。我会在每次班次结束时把history数组渲染成一张 Canvas 折线图横轴是订单顺序纵轴是现金余额和疲劳值。两条线一上一下比任何文字都有说服力。你不需要引入图表库用 Canvas 直接画就行代码量不大还能避免打包体积膨胀。function drawHistory(canvas, history) { const ctx canvas.getContext(2d); const w canvas.width; const h canvas.height; ctx.clearRect(0, 0, w, h); const maxMoney Math.max(...history.map(p p.money)) * 1.2; const maxFatigue 10; history.forEach((p, i) { const x (i / (history.length - 1)) * w; const yMoney h - (p.money / maxMoney) * h; const yFatigue h - (p.fatigue / maxFatigue) * h; ctx.fillStyle #4e54c8; ctx.fillRect(x, yMoney, 3, 3); ctx.fillStyle #e06c75; ctx.fillRect(x, yFatigue, 3, 3); }); }每次history.push时调一次drawHistory玩家就能实时看到自己的经济曲线往上走、疲劳线也往上走。这种可视化让“赚钱”和“疲惫”不再是两个抽象数字而是两条在赛跑的线。多结局也可以从这里展开班次结束如果疲劳值超过 8走“第二天腰酸背痛”结局余额低于 50 走“白干一晚”结局超过 180 走“今天还算幸运”结局。判定逻辑放在end_shift节点之前的 React 层Ink 只负责读结果。验证方法上我先跑 1000 次固定 seed 的模拟把所有结局的比例拉出来看看经济模型是不是偏得离谱再去剪辑文案。这个顺序别反剧本写好了再调模型会舍不得删句子。那才叫真翻车。最后说个我的习惯做完一个版本先自己玩三遍不打开 Console只看曲线和结局如果有一关让我觉得没得选那就是叙事出问题了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?