资讯详情 用JavaScript构建数据驱动的交互式叙事模型
📅 2026/10/18 8:54:00
简介这是一款由英国《金融时报》开发的叙事性新闻游戏以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如何把政策文档翻译成可运算的 JSONdata/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: 来自美国汽车协会AAA2022 年发布的网约车车辆持有成本报告已换算为日均值参与日结算。当你修改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 classnarrative-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 5chain_rides: 连续接单会额外增加fatigue 15故设安全阈值 85go_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.netfatiguefamilyTime典型结局文案Q105050“你维持着收支平衡也保住了生活节奏。”Q205050“收入尚可但家人开始抱怨你的缺席。”Q305050“你成了平台最勤奋的司机也成了家里最熟悉的陌生人。”Q405050“你在疲惫中找到了节奏家人也习惯了你的‘随时在线’。”Q505050“收入告急但至少今晚能陪孩子写作业。”Q605050“账单堆积如山而你连解释的时间都没有。”Q705050“你倒在方向盘上时手机还亮着未读的催款通知。”Q805050“你关掉接单软件把车停在空旷的停车场第一次感到自由。”判定代码简洁到令人意外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${(x1?1:0)*4 (y1?1:0)*2 (z1?1:0)1}; }玄学经验测试时发现当fatigue和familyTime同时低于 30net却为正——这对应现实中“靠透支健康换取短期收入”的群体结局 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 charsetUTF-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: flocal_{i1: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, encodingutf-8) as f: json.dump(output, f, ensure_asciiFalse, indent2)运行后得到local_interviews.json再手动合并进主interviews.json。5.2 参数本地化替换economics.json中的 4 个核心字段根据本地调研修改data/economics.json的以下字段务必保留原有结构字段原值本地调研建议值依据fuel_per_mile0.180.25本地 92# 汽油 7.8 元/升主流车型百公里油耗 8L换算为 0.25 美元/英里platform_commission_rate0.250.28本地平台 2023 年公告的“高峰时段服务费”叠加后实际抽成insurance_monthly145210本地网约车营运险报价含第三者责任险 150 万depreciation_monthly220180本地二手车市场同车型 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*51.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计算中被错误应用两次从那以后我每次做本地化改造都强制走一遍这三组验证——不是为了“通过测试”而是确保模型始终忠于它声称要表达的那个世界。希望帮到你。本文还有配套的精品资源点击获取