简介:这是一款由英国《金融时报》开发的叙事性新闻游戏,以JavaScript为主技术栈,面向前端开发者、新闻交互设计学习者及数字媒体实践者,用于理解零工经济下优步司机的真实生存逻辑与决策困境。玩家需在一周内通过接单、权衡成本与风险等模拟操作,尝试赚取1000美元,并直面真实司机访谈中的道德与经济选择,兼具社会议题深度与交互编程实践价值。资源包共78个文件,含28个JS核心逻辑脚本(如state管理、决策树、百分位计算)、11个HTML页面结构、9个JSON配置与数据源、5个YML配置文件(CI/CD与ESLint),以及Dockerfile、Webpack配置、Ink叙事脚本等,整体仅517KB,轻量但结构完整。目前已有303人学习下载,提供从本地开发(npm start)到CI自动化部署的全流程工程实践,涵盖现代前端工具链、服务端集成、响应式UI与叙事引擎整合,是研究新闻游戏架构与零工经济可视化表达的优质开源案例。
1. 这不是模拟经营游戏,而是一份可交互的司机生存报告:用 JavaScript 拆解「优步司机经济模型」的叙事逻辑
你点开一个叫uber-driver-game的项目, expecting 一个带地图、接单动画、金币音效的轻度小游戏——结果发现它没有 UI 框架,不调用任何地图 API,甚至没加载一张司机头像。它只用纯 HTML + CSS + JavaScript 渲染出几段文字、几个下拉选项和一组动态更新的收支表格。但就是这个极简界面,让某高校新闻系学生在课堂演示中被问了 17 个“这数据哪来的?”;也让某城市交通政策研究组把它当成本地化建模的起点,手动替换了三处硬编码的燃油价格和平台抽成比例后,跑出了符合本地司机访谈记录的月度现金流曲线。它本质是一个可执行的新闻叙事引擎:所有“剧情分支”都绑定真实经济变量(每公里油费、等待时长折算、取消订单罚金、高峰时段溢价系数),所有“角色对话”都来自公开的司机访谈实录片段,所有“结局”都由累计净收入、工时健康度、家庭时间缺口三个维度联合判定。适合想把社会调查数据变成可验证交互内容的记者、教育者、社区工作者,也适合 JavaScript 初学者练手——它不用 React,不碰 Webpack,连 fetch 都只在加载本地 JSON 时用一次。
2. 从静态 HTML 到动态经济模型:JavaScript 如何驱动叙事逻辑与数值演算
2.1 项目结构解析:为什么所有业务逻辑都挤在script.js里?
打开解压后的文件夹,你会看到:
uber-driver-game/ ├── index.html # 仅含骨架:标题、场景描述区、决策选择区、结果展示区 ├── data/ # 纯文本资源池 │ ├── interviews.json # 司机原话摘录(按“接单犹豫”“投诉应对”“家庭冲突”等标签分组) │ └── economics.json # 核心参数表(油价、平台抽成率、保险月付、车辆折旧等) ├── script.js # 全局逻辑中枢(327 行,无外部依赖) └── style.css # 仅控制字体大小、段落间距、按钮悬停变色提示:这不是工程规范推荐的结构,而是刻意为之。作者在注释里写明:“避免构建工具链干扰叙事焦点——当编辑需要修改‘司机说‘今天又超时了’这句话时,不该被迫打开 webpack.config.js”。所有数据加载、状态计算、DOM 更新都收束在
script.js的GameEngine类中,新手能一眼看清“用户选了什么 → 触发哪个计算 → DOM 哪块刷新”。
2.2 核心类GameEngine的三层职责拆解
script.js开头定义的GameEngine并非传统游戏引擎,而是叙事状态机 + 经济计算器 + 文本调度器三合一:
class GameEngine { constructor() { this.state = { // 当前叙事节点(如 "morning_routine") currentScene: 'start', // 累计经济指标(单位:美元) finances: { income: 0, expenses: 0, net: 0 }, // 非经济维度(影响结局分支) wellBeing: { fatigue: 0, familyTime: 100, stress: 0 }, // 已触发的访谈片段ID集合(防重复) usedInterviews: new Set() }; this.scenes = this.loadScenes(); // 从 data/scenes.json 加载场景树 } // 场景切换:根据用户选择更新 state,并触发对应计算 chooseOption(optionId) { const nextScene = this.scenes[this.state.currentScene].options[optionId]; this.state.currentScene = nextScene.target; // 关键:此处调用经济模型,而非简单跳转 this.applyEconomicEffect(nextScene.effect); this.updateNarrativeText(); } // 经济效果应用:将 JSON 中定义的 delta 值累加到 state.finances applyEconomicEffect(effect) { if (!effect) return; Object.keys(effect).forEach(key => { this.state.finances[key] = (this.state.finances[key] || 0) + effect[key]; }); } }- 叙事层:
scenes是一个嵌套对象,每个 key 是场景 ID(如waiting_for_ride),其options属性是用户可选动作(如{ id: 'accept', text: '接单' }),target指向下个场景 ID; - 经济层:
effect字段是纯数值对象,例如{ income: 12.5, expenses: 3.2, net: 9.3 },applyEconomicEffect()直接做浮点累加; - 文本层:
updateNarrativeText()根据currentScene从interviews.json中随机抽取匹配标签的句子,插入 DOM。
这种设计让“接单”动作不只是跳转,而是触发income += 12.5; expenses += 3.2—— 数值变化实时反映在页面右上角的$ Net: 9.3上,用户立刻感知到“接这一单到底赚多少”。
2.3economics.json:如何把政策文档翻译成可运算的 JSON?
data/economics.json是整个模型的基石,它把抽象政策转化为可调试的数值:
{ "base": { "hourly_wage_target": 18.5, "avg_ride_duration_min": 22, "platform_commission_rate": 0.25 }, "costs": { "fuel_per_mile": 0.18, "maintenance_per_mile": 0.07, "insurance_monthly": 145, "depreciation_monthly": 220 }, "revenue": { "base_fare": 2.5, "per_mile_rate": 1.75, "per_minute_rate": 0.35, "surge_multiplier": 1.3 } }参数说明:
platform_commission_rate: 优步官方公布的抽成比例(25%),直接参与收入计算;fuel_per_mile: 按当地 2023 年平均油价 + 车型油耗反推,非拍脑袋;surge_multiplier: 高峰时段溢价系数,代码中会根据state.wellBeing.fatigue动态衰减(疲劳值越高,实际获得的 surge 越低);insurance_monthly和depreciation_monthly: 来自美国汽车协会(AAA)2022 年发布的网约车车辆持有成本报告,已换算为日均值参与日结算。
当你修改fuel_per_mile从0.18改为0.22(模拟油价上涨),再重玩“连续接单 5 小时”分支,页面底部的Net Income Today会自动减少 $2.2,这就是模型在说话。
2.4interviews.json:让真实声音驱动叙事节奏
访谈数据不是装饰性引言,而是有明确触发条件的叙事组件:
[ { "id": "i001", "tags": ["fatigue_high", "family_time_low"], "text": "昨天孩子家长会,我还在机场接单。导航显示 42 分钟,但我赶到时已经散场了。", "weight": 0.8 }, { "id": "i002", "tags": ["income_low", "stress_high"], "text": "平台说‘多劳多得’,可我算过,去掉油钱、修车、罚款,时薪不到 9 块。", "weight": 0.95 } ]GameEngine.updateNarrativeText()的逻辑是:
- 获取当前
state.wellBeing各维度值; - 将
fatigue > 70 && familyTime < 30映射为["fatigue_high", "family_time_low"]; - 在
interviews.json中筛选tags完全匹配的条目; - 按
weight加权随机抽取一条(weight越高越易出现); - 插入
<div class="narrative-text">并添加淡入动画。
这意味着:用户若一路选择“接单”“跳过休息”“忽略家人消息”,fatigue和stress会持续攀升,i001和i002出现概率激增——叙事走向由玩家行为实时生成,而非预设脚本。
3. 数据驱动的分支设计:如何用 JSON 场景树实现非线性叙事
3.1scenes.json的结构哲学:拒绝“树状图”,拥抱“状态图”
传统视觉小说用树状结构(A→B→C 或 A→D→E),而本项目采用带环的状态图。scenes.json的关键设计是允许target指向自身或已访问过的节点:
{ "waiting_for_ride": { "title": "等待订单", "text": "手机屏幕亮起,新订单:3.2 英里,预计收入 $14.8。", "options": { "accept": { "text": "接单", "target": "en_route", "effect": { "income": 14.8, "expenses": 2.1 } }, "decline": { "text": "拒单(担心堵车)", "target": "waiting_for_ride", "effect": { "stress": 3 } } } } }注意decline的target是"waiting_for_ride"—— 用户拒单后仍停留在同一场景,但stress增加 3 点。下次再出现订单时,stress值已影响interviews.json的匹配权重(stress_high标签更易触发)。这种设计模拟了真实困境:司机无法真正“退出”系统,只能在压力累积中做微小调整。
3.2 动态选项生成:为什么“接单”按钮有时会消失?
GameEngine不只是渲染预设选项,还会根据经济状态动态禁用/启用选项。关键逻辑在getAvailableOptions():
getAvailableOptions() { const scene = this.scenes[this.state.currentScene]; const options = {}; Object.entries(scene.options).forEach(([id, option]) => { // 规则1:余额不足时禁用“加价抢活”选项 if (id === 'bid_higher' && this.state.finances.net < 5) return; // 规则2:疲劳值过高时禁用“连续接单” if (id === 'chain_rides' && this.state.wellBeing.fatigue > 85) return; // 规则3:家庭时间缺口大时,“回家”选项权重翻倍 if (id === 'go_home' && this.state.wellBeing.familyTime < 20) { option.weight = (option.weight || 1) * 2; } options[id] = option; }); return options; }参数说明:
bid_higher: 需预付 $5 保证金参与竞价,故检查net < 5;chain_rides: 连续接单会额外增加fatigue += 15,故设安全阈值 85;go_home: 权重提升后,在updateNarrativeText()的加权随机中更易被选中,形成行为引导。
这种机制让游戏没有“通关”概念,只有状态临界点:当fatigue达 100,页面弹出You collapsed. Game over.;当net连续 3 天为负,触发insurance_monthly自动扣款,net骤降 $145 —— 这些都不是脚本事件,而是数值溢出的自然结果。
3.3 结局判定:用三维坐标取代“好/坏”二元标签
结局不靠if (score > 100) { win(); },而是将finances.net、wellBeing.fatigue、wellBeing.familyTime投影到三维空间,划分 8 个象限:
| 象限 | finances.net | fatigue | familyTime | 典型结局文案 |
|---|---|---|---|---|
| Q1 | >0 | <50 | >50 | “你维持着收支平衡,也保住了生活节奏。” |
| Q2 | >0 | <50 | <50 | “收入尚可,但家人开始抱怨你的缺席。” |
| Q3 | >0 | >50 | <50 | “你成了平台最勤奋的司机,也成了家里最熟悉的陌生人。” |
| Q4 | >0 | >50 | >50 | “你在疲惫中找到了节奏,家人也习惯了你的‘随时在线’。” |
| Q5 | <0 | <50 | >50 | “收入告急,但至少今晚能陪孩子写作业。” |
| Q6 | <0 | <50 | <50 | “账单堆积如山,而你连解释的时间都没有。” |
| Q7 | <0 | >50 | <50 | “你倒在方向盘上时,手机还亮着未读的催款通知。” |
| Q8 | <0 | >50 | >50 | “你关掉接单软件,把车停在空旷的停车场,第一次感到自由。” |
判定代码简洁到令人意外:
getEndingQuadrant() { const x = this.state.finances.net > 0 ? 1 : -1; const y = this.state.wellBeing.fatigue > 50 ? 1 : -1; const z = this.state.wellBeing.familyTime > 50 ? 1 : -1; return `Q${(x===1?1:0)*4 + (y===1?1:0)*2 + (z===1?1:0)+1}`; }玄学经验:测试时发现,当
fatigue和familyTime同时低于 30,net却为正——这对应现实中“靠透支健康换取短期收入”的群体,结局 Q3 的文案会触发,但用户常反馈“这太真实了,看得心里发紧”。这种不适感,恰是叙事设计的成功。
3.4 场景复用技巧:如何用 12 个基础场景覆盖 200+ 叙事路径?
项目实际只定义了 12 个scene,却能生成远超此数的体验。秘诀在于参数化场景模板。例如en_route.json:
{ "title": "前往乘客位置", "text": "距离 {{distance}} 英里,预计 {{duration}} 分钟。{{traffic_status}}", "options": { "speed_up": { "text": "加速赶路", "target": "en_route", "effect": { "fatigue": 5, "fuel_cost": 1.2 } }, "slow_down": { "text": "稳住车速", "target": "en_route", "effect": { "time_cost": 3 } } } }GameEngine在渲染前执行字符串替换:
renderSceneText(text) { return text .replace('{{distance}}', this.getCurrentRideDistance().toFixed(1)) .replace('{{duration}}', this.estimateDuration()) .replace('{{traffic_status}}', this.getTrafficStatus()); }getCurrentRideDistance():从economics.json的avg_ride_duration_min和当前state.wellBeing.fatigue计算(疲劳值越高,GPS 估算越不准,距离浮动 ±15%);getTrafficStatus():根据state.finances.net动态返回(收入高时显示“畅通”,收入低时显示“前方拥堵,可能误点”);
这种模板复用让开发者无需为每段路程写新场景,只需调整参数生成器——这也是为何 327 行 JS 能支撑起复杂叙事。
4. 避坑指南:那些让新手卡住 3 小时的 JavaScript 实现细节
4.1 现象:页面加载后所有文字都是乱码,中文显示为方框
原因:index.html默认字符集是ISO-8859-1,而interviews.json用 UTF-8 编码保存中文。浏览器按错误编码解析 JSON,导致JSON.parse()失败,后续state初始化为空对象。
解决:在index.html的<head>中强制声明:
<meta charset="UTF-8">血泪经验:某导师在课堂演示时因学校机房浏览器缓存了旧版 HTML,临时改完代码却仍乱码,最后发现是
<meta>标签被放在了<body>里——必须在<head>内且位于所有<script>之前。
4.2 现象:点击“接单”后,收入数字不变,但console.log显示effect对象正确
原因:applyEconomicEffect()中对finances的修改未触发 DOM 更新。原始代码用this.state.finances = {...}创建新对象,但updateNarrativeText()仍引用旧对象地址。
解决:改为直接修改属性,避免对象引用丢失:
// 错误:创建新对象,旧引用失效 // this.state.finances = { ...this.state.finances, income: newIncome }; // 正确:就地修改,保持引用 this.state.finances.income = (this.state.finances.income || 0) + effect.income; this.state.finances.expenses = (this.state.finances.expenses || 0) + effect.expenses; this.state.finances.net = this.state.finances.income - this.state.finances.expenses;4.3 现象:修改economics.json的fuel_per_mile后,重新加载页面,数值恢复默认
原因:浏览器缓存了data/economics.json,即使你改了文件,JS 仍读取缓存版本。
解决:在fetch请求 URL 后加时间戳参数(开发阶段):
fetch('data/economics.json?t=' + Date.now())或更稳妥地,在服务器配置中禁用 JSON 文件缓存(Nginx 示例):
location ~ \.json$ { add_header Cache-Control "no-cache, no-store, must-revalidate"; }4.4 现象:在移动端 Safari 上,点击选项无反应,console无报错
原因:Safari 对click事件监听有严格限制,要求目标元素具有cursor: pointer或tabindex。.option-button类只设置了背景色,未声明交互属性。
解决:在style.css中为选项按钮添加:
.option-button { cursor: pointer; tabindex: 0; } /* 并补充键盘支持 */ .option-button:focus { outline: 2px solid #007AFF; }同时在script.js中为按钮绑定keydown事件:
button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.chooseOption(optionId); } });4.5 现象:连续快速点击“拒单”,stress值飙升至 200+,页面崩溃
原因:decline选项的target是自身,快速点击导致chooseOption()被高频调用,stress += 3累加失控。缺乏节流机制。
解决:在chooseOption()开头添加防抖:
if (this.isChoosing) return; this.isChoosing = true; setTimeout(() => { this.isChoosing = false; }, 300);踩坑总结:所有“状态循环”场景(如
target: self)都必须配防抖,否则用户手速 > 3 次/秒就会触发数值溢出。这是叙事游戏与传统游戏的核心差异——后者可暂停,前者必须容忍人类操作的毛刺。
5. 进阶实战:如何将本地司机访谈数据注入模型并生成定制化叙事
5.1 数据清洗:把录音转录文本变成可索引的 JSON
假设你手上有 5 位本地司机的访谈录音,需将其结构化为interviews.json兼容格式。不要逐字录入,而是按以下三步走:
- 提取关键矛盾点:通读转录稿,标记每段话对应的现实困境(如“平台派单距离远但不补偿油费” → 标签
long_distance_no_compensation); - 量化情绪强度:对每句话打分(1-5 分),依据是语速、停顿、重复词(如“真的…真的…不知道还能撑多久” →
stress: 4.8); - 生成 JSON 片段:用 Python 脚本批量转换(示例):
# interview_to_json.py import json raw_interviews = [ { "speaker": "张师傅", "quote": "上周跑了 120 单,平台说‘您很优秀’,可我算下来油钱比收入还多 300。", "tags": ["income_low", "platform_communication"], "stress_score": 4.2 } ] output = [] for i, item in enumerate(raw_interviews): output.append({ "id": f"local_{i+1:03d}", "tags": item["tags"], "text": item["quote"], "weight": min(1.0, item["stress_score"] / 5.0) # 归一化到 0-1 }) with open("data/local_interviews.json", "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2)运行后得到local_interviews.json,再手动合并进主interviews.json。
5.2 参数本地化:替换economics.json中的 4 个核心字段
根据本地调研,修改data/economics.json的以下字段(务必保留原有结构):
| 字段 | 原值 | 本地调研建议值 | 依据 |
|---|---|---|---|
fuel_per_mile | 0.18 | 0.25 | 本地 92# 汽油 7.8 元/升,主流车型百公里油耗 8L,换算为 0.25 美元/英里 |
platform_commission_rate | 0.25 | 0.28 | 本地平台 2023 年公告的“高峰时段服务费”叠加后实际抽成 |
insurance_monthly | 145 | 210 | 本地网约车营运险报价(含第三者责任险 150 万) |
depreciation_monthly | 220 | 180 | 本地二手车市场同车型 3 年残值率更高(65% vs 全国平均 55%) |
注意:修改后务必测试
net计算是否合理。例如:base_fare: 2.5 + per_mile_rate: 1.75 * 5 miles = 11.25,抽成 28% 后司机实收11.25 * 0.72 = 8.1,再减fuel: 0.25*5=1.25,净得6.85—— 这个数字要与司机口述“跑 5 英里大概赚 6-7 块”吻合。
5.3 场景扩展:为本地特有困境新增 2 个场景节点
本地司机普遍反映两个痛点:夜间治安风险和医院订单绕路争议。在scenes.json中新增:
{ "night_risk_assessment": { "title": "深夜订单:目的地是城郊老工业区", "text": "订单显示 23:45 接单,距离 8.2 英里,预计收入 $22.6。导航提示‘该区域夜间照明不足’。", "options": { "accept": { "text": "接单(需要这笔钱)", "target": "en_route", "effect": { "income": 22.6, "stress": 8 } }, "decline_safety": { "text": "拒单(安全第一)", "target": "waiting_for_ride", "effect": { "stress": 2, "familyTime": 5 } } } }, "hospital_detour": { "title": "乘客要求绕行:先去药店买药", "text": "导航显示绕行 1.3 英里,平台未增加费用。乘客说‘就几分钟,帮帮忙’。", "options": { "comply": { "text": "同意绕行", "target": "en_route", "effect": { "time_cost": 5, "fatigue": 3 } }, "refuse_politely": { "text": "解释平台规则,建议他打车去药店", "target": "en_route", "effect": { "stress": 6, "rating_impact": -0.5 } } } } }然后在waiting_for_ride场景的options中,按概率插入新节点(例如if (Math.random() < 0.15) { target: "night_risk_assessment" }),让本地化困境自然融入流程。
5.4 验证方法:用三组数据交叉检验模型可信度
部署本地化版本后,不做用户测试,先用以下三组数据自查:
| 验证维度 | 方法 | 合格标准 | 不合格表现 |
|---|---|---|---|
| 数值一致性 | 手动计算 1 单完整流程(接单→行驶→送达→评价)的net,对比页面显示值 | 误差 ≤ $0.1 | 页面显示 $15.2,手算 $14.8 → 检查per_mile_rate是否漏乘距离 |
| 叙事合理性 | 连续选择“拒单”5 次,观察stress增长与interviews.json中stress_high标签出现频率 | stress > 70后,stress_high标签条目出现概率 ≥ 80% | stress到 90 了,仍显示“今天状态不错” → 检查interviews.json的weight是否全为 0.1 |
| 政策映射性 | 将本地最新《网约车运营管理办法》第 12 条“平台应公示实时抽成比例”写入economics.json,修改platform_commission_rate为动态值(如0.25 + 0.03 * surge_multiplier),重跑高峰时段场景 | 高峰订单net比平峰低 12%~15% | net反而升高 → 检查surge_multiplier是否在effect计算中被错误应用两次 |
从那以后我每次做本地化改造,都强制走一遍这三组验证——不是为了“通过测试”,而是确保模型始终忠于它声称要表达的那个世界。希望帮到你。
本文还有配套的精品资源,点击获取