
这篇技术文章来自一个很特别的标题“至冬公共列车的列车长是鬼”如果把这三个问号当成一次需求评审那它就是一道典型的“叙事型交互项目”题目。我们可以把它拆成一个真正能跑起来的 Demo列车长到底是不是鬼玩家通过收集线索、推理、选择最终答案由系统判定结局。整个项目涉及到状态机设计、对话解析、后端接口、前端交互很适合作为 FastAPI 入门到实践的练手案例。下面我基于这个场景带大家从零开始实现一个“幽灵列车长推理系统”。整个项目以 Python FastAPI 为主不依赖重型框架既能跑通流程也能学到不少工程化设计思路。1. 背景与核心概念1.1 这个标题背后是什么需求“至冬公共列车的列车长是鬼” 这句话如果出现在产品需求文档里通常不是一个事实判断而是一个叙事设定。它意味着我们需要构建一个交互场景玩家乘坐一辆名为“至冬公共列车”的列车遇到一位看起来不太对劲的列车长需要通过对话和线索调查最终判断列车长是不是“鬼”。在技术侧这属于“分支叙事 对话系统 状态管理”的常见组合。类似的应用场景包括密室逃脱小程序、剧本杀线上版、游戏 NPC 对话、互动小说、甚至企业内部的培训仿真系统。它们的共同点在于用户输入一段文本系统根据当前剧情阶段、用户历史行为和关键线索返回一段新的剧情文本并更新全局状态。所以这本质上不是一篇灵异故事而是一篇教你如何用状态机驱动复杂对话流程的技术文章。标题只不过是一个更有画面感的项目需求。1.2 我们需要构建什么为了讲清楚实现思路我会做一个最小但完整的“幽灵列车长推理系统”功能如下玩家进入“至冬公共列车”车厢系统输出当前场景描述玩家输入自然语言例如“查看镜子”“捡起日记”“询问列车长”系统解析关键词推进剧情玩家在关键节点选择“他是鬼”或“他不是鬼”系统根据玩家持有的线索判定结局整个过程通过 Web API 提供前端页面可以实时对话。整个项目不依赖游戏引擎只使用 FastAPI 和 Python 标准库。这样设计的好处是你可以轻而易举地替换成其他语言或其他 Web 框架逻辑本身是通用的。1.3 技术选型与边界先声明一个问题这里不会接入大语言模型也不调用任何外部 AI 接口。原因是剧情推理游戏的体验不在于“自由聊天”而在于“可控分支”。如果让大模型自由发挥剧情很容易跑偏玩家会得到一堆不连贯的回答。因此我们采用“关键词匹配 状态机”的方案这是当前互动叙事项目中最稳定、最容易调试的做法。如果你希望后续加入大模型能力我也会在文末的最佳实践部分给出扩展思路例如让大模型负责生成描述性文本但仍由状态机决定剧情走向。2. 环境准备与项目结构2.1 环境说明本文的代码以 Python 3.10 为例FastAPI 使用当前常见稳定版本。如果你使用的是 Python 3.8 或 3.9建议把类型标注中的list[str]改成List[str]并从typing导入List。建议使用虚拟环境安装依赖python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate安装依赖pip install fastapi uvicorn[standard] pydantic版本不需要和某个固定版本完全一致重点是 FastAPI 和 Pydantic v2 兼容。如果你用的是 Pydantic v1部分响应模型写法可能略有差异我会在文中按 v2 语法来写。2.2 项目目录结构为了便于维护我把项目拆成四个核心文件ghost_conductor/ ├── scenario.py # 剧情节点配置 ├── engine.py # 状态机与对话解析引擎 ├── main.py # FastAPI 接口层 ├── static/ │ └── index.html # 浏览器聊天页面 └── requirements.txt # 依赖清单scenario.py负责“写剧本”engine.py负责“跑剧本”main.py负责“接请求”index.html负责“展示画面”。这样分层的好处是改了剧情不用动代码逻辑改了接口不用动剧本。3. 核心设计剧本、状态机与关键词解析3.1 剧情节点如何设计在互动叙事系统里一个“节点”代表一个剧情时刻。每个节点包含以下信息id节点唯一标识text当前场景描述文本options玩家可以执行的动作或询问keywords该节点下哪些关键词能触发动作next动作触发后跳转到哪个节点conditions跳转是否需要前置线索add_flag进入下一节点后需要记录哪些线索。例如玩家在“车厢入口”节点输入“查看镜子”系统会检查当前节点是否包含“镜子”关键词如果有则跳转到“镜面异象”节点并且记录has_seen_mirror True。我把剧情设计成一条带分支的链主流程如下start上车广播响起ticket_check列车长查票发现他没有影子mirror乘客在车厢镜子里看到怪异身影journal捡到值班日志看到关键记录truth_choice选择“他是鬼”还是“他不是鬼”ending_bad判断错误惊吓结局ending_good判断正确真相结局。为了让玩家不是直接跳到最后一步我在truth_choice节点设置了线索条件必须同时持有has_seen_mirror和has_read_journal两个线索才会出现“冷静推理”的选项否则只能凭猜测进入坏结局。3.2 状态机核心思路状态机的本质是当前状态 用户输入 下一个状态。但这里比普通状态机多了一点输入不是严格的指令而是自然语言。所以我们要在中间加一层“意图识别”把用户输入解析成动作。具体步骤如下用户输入字符串引擎根据当前节点的options列表检查用户输入是否包含某个动作关键词如果匹配执行跳转如果没匹配返回当前节点的提示文本让玩家重新输入跳转时更新线索标志位。这里的核心数据是“节点表”和“玩家状态”。玩家状态包括current_node: str flags: set[str]用flags集合记录玩家收集到的线索可以避免重复添加也方便后续条件判断。3.3 对话解析为什么不直接用大模型关键词匹配看起来有点“笨”但它有两个优势可预测、可测试。在推理游戏中如果大模型自由发挥很可能会把玩家带到剧情设计之外的地方。比如玩家问“列车长早餐吃了什么”大模型可能会编一段和主线无关的回答。而关键词匹配能保证玩家始终在剧本框架内行动最多只是得到“我听不懂你在说什么”的提示不会破坏剧情闭环。那是不是完全不能用大模型也不是。更成熟的方案是“混合架构”用状态机保证主线结构用大模型润色段落文本或者在玩家输入偏离主线时由大模型生成临时反应。但我们这个 Demo 先从最稳的关键词匹配开始。4. 完整实战案例实现幽灵列车长推理系统4.1 创建项目并安装依赖先创建项目目录和虚拟环境mkdir ghost_conductor cd ghost_conductor python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate写一份requirements.txtfastapi uvicorn[standard] pydantic安装pip install -r requirements.txt4.2 编写剧情配置 scenario.py这个文件是整个项目的“剧本”全部节点都定义在这里。为了让代码更清晰我使用字典列表的方式保存节点。# 文件路径ghost_conductor/scenario.py from typing import Optional class StoryNode: 剧情节点定义 def __init__( self, node_id: str, text: str, options: Optional[list[dict]] None, ): self.node_id node_id self.text text self.options options or [] def build_scenario() - dict[str, StoryNode]: 构建所有剧情节点 nodes {} nodes[start] StoryNode( node_idstart, text( 你登上至冬公共列车车厢里灯光昏暗暖气片发出滋滋的响声。\n 广播响起欢迎乘坐至冬公共列车请乘客们保管好随身物品。\n 你注意到列车长站在车厢另一端背对着你一动不动。\n 你接下来想做什么\n 可选动作询问列车长 / 找座位坐下 ), options[ { action: 询问列车长, keywords: [列车长, 询问, 你好, 您好], next: ticket_check, }, { action: 找座位坐下, keywords: [坐下, 座位, 找座位], next: start, hint: 你刚坐下又忍不住看向那位列车长。, }, ], ) nodes[ticket_check] StoryNode( node_idticket_check, text( 列车长缓缓转过身来他戴着笔挺的帽子脸上挂着礼貌而机械的微笑。\n 请出示您的车票。他的声音很轻像从很远处传来。\n 你低头翻找车票抬头时突然发现灯光下他没有影子。\n 你心里一紧。\n 可选动作询问影子 / 查看车厢镜子 / 沉默离开 ), options[ { action: 询问影子, keywords: [影子, 没有影子, 你, 问], next: start, hint: 列车长没有回答只是重复了一遍请出示您的车票。, }, { action: 查看车厢镜子, keywords: [镜子, 查看, 观察], next: mirror, add_flag: has_entered_carriage, }, { action: 沉默离开, keywords: [离开, 沉默, 走], next: start, hint: 你退到座位边但心跳得厉害。, }, ], ) nodes[mirror] StoryNode( node_idmirror, text( 你走向车厢尽头的洗手间那里有一面蒙着薄霜的镜子。\n 镜子里你看到身后站着一个模糊的身影。\n 可当你猛地回头身后什么也没有。\n 再看向镜子身影又消失了只剩你一脸惊恐的脸。\n 你忽然发现镜面上有一行用指尖划出的字他不是鬼是镜子。\n 这个信息来得莫名其妙。\n 可选动作捡起地上的日志 / 回去找列车长 ), options[ { action: 捡起日志, keywords: [日志, 日记, 捡, 本子], next: journal, add_flag: has_seen_mirror, condition_flag: None, }, { action: 回去找列车长, keywords: [列车长, 回去, 找他], next: truth_choice, condition_flag: has_seen_mirror, }, ], ) nodes[journal] StoryNode( node_idjournal, text( 你蹲下身捡起一本泛黄的值班日志。\n 日志上写着一行字\n 暴风雪夜列车长在检查车厢时滑倒撞到头部。为了不延误乘客 副列车长换上他的制服继续值班。大家看到的并不是鬼而是镜面反射中的副列车长。\n 你终于明白为什么他没有影子——因为灯光角度和镜面折射造成了视觉误差。\n 现在你掌握了两条重要线索镜子里的字、值班日志。\n 可选动作回去找列车长 ), options[ { action: 回去找列车长, keywords: [列车长, 回去, 找他], next: truth_choice, condition_flag: has_read_journal, add_flag: has_read_journal, } ], ) nodes[truth_choice] StoryNode( node_idtruth_choice, text( 你再次站在列车长面前。他依然面带微笑静静地看着你。\n 现在你必须做出判断\n A. 他是鬼。\n B. 他不是鬼。 ), options[ { action: 他是鬼, keywords: [鬼, 是鬼, A], next: ending_bad, condition_flag: None, }, { action: 他不是鬼, keywords: [不是鬼, 不是, B], next: ending_true, condition_flag: has_read_journal, }, ], ) nodes[ending_bad] StoryNode( node_idending_bad, text( 你大喊一声你是鬼\n 列车长微微一愣随后脸上的笑容慢慢扩大。\n 车厢里的灯忽然熄灭你听到身后传来金属摩擦声。\n 当你重新睁开眼睛时列车长已经不见了只有一条空荡荡的走廊。\n 这是一个坏结局。你凭借猜测做出了判断却没能找到真正的答案。\n 游戏结束。你可以重新开始继续收集线索。 ), options[], ) nodes[ending_true] StoryNode( node_idending_true, text( 你深吸一口气说你不是鬼你是副列车长。 \n 列车长的笑容终于有了温度。他摘下帽子露出缠着绷带的额头。\n 聪明。暴风雪夜列车长先生受了伤我临时接替了他的工作。 因为镜面折射和灯光角度很多乘客把我在镜子里的倒影当成了幽灵。\n 列车重新启动窗外的风雪渐渐变小。\n 这是一个好结局。你通过细节和日志解开了鬼列车长之谜。\n 游戏结束。 ), options[], ) return nodes这份剧本里有一个需要特别注意的设计mirror节点中“捡起日志”动作会添加has_seen_mirror标志journal节点中“回去找列车长”动作会添加has_read_journal标志。这样确保玩家在truth_choice选择“他不是鬼”时已经同时持有两条关键线索。4.3 实现状态机与对话解析引擎 engine.py接下来是核心逻辑。这个文件负责保存每个玩家的当前节点和线索根据用户输入匹配动作更新玩家状态返回下一节点文本、可用动作和是否游戏结束。# 文件路径ghost_conductor/engine.py from dataclasses import dataclass, field from scenario import StoryNode, build_scenario dataclass class PlayerState: 玩家状态 current_node: str start flags: set[str] field(default_factoryset) class StoryEngine: 剧情引擎负责节点跳转和输入解析 def __init__(self, scenario: dict[str, StoryNode]): self.scenario scenario self.player PlayerState() def restart(self): 重置玩家状态 self.player PlayerState() def get_current_node(self) - StoryNode: 获取当前节点 return self.scenario[self.player.current_node] def parse_input(self, user_input: str) - tuple[StoryNode, str, bool]: 解析用户输入。 返回三元组 next_node: 跳转后的节点 message: 给用户的提示信息 is_end: 是否游戏结束 user_input user_input.strip() current_node self.get_current_node() # 如果没有配置任何动作说明是结局节点 if not current_node.options: return current_node, 游戏已经结束。输入“重新开始”可以再玩一次。, True # 遍历当前节点的所有动作尝试关键词匹配 for option in current_node.options: keywords option.get(keywords, []) if any(keyword in user_input for keyword in keywords): # 检查条件是否满足 require_flag option.get(condition_flag) if require_flag and require_flag not in self.player.flags: continue # 执行跳转 next_node_id option[next] self.player.current_node next_node_id # 添加线索 add_flag option.get(add_flag) if add_flag: self.player.flags.add(add_flag) # 返回跳转后的节点 next_node self.get_current_node() return next_node, next_node.text, len(next_node.options) 0 # 没有匹配到任何关键词给出提示 return current_node, current_node.text, False def get_available_actions(self) - list[str]: 获取当前节点可用动作用于前端展示 current_node self.get_current_node() actions [] for option in current_node.options: actions.append(option.get(action, )) return [a for a in actions if a]这里需要注意parse_input返回的is_end是在跳转后判断的。如果跳转后的节点没有options说明已经到达结局节点。前端拿到is_endTrue后可以显示“游戏结束”并提供重开按钮。另外关键词匹配使用的是简单的in判断比如用户输入“我想问列车长问题”也会命中“列车长”触发ticket_check节点。这在小型 Demo 里足够用但在生产环境里可以换成更细致的意图识别方案后面我会提到。4.4 编写 FastAPI 接口层 main.py接口层负责把引擎包装成 Web 服务。这里定义两个接口POST /api/interact接收用户输入返回剧情响应POST /api/restart重置游戏GET /返回前端页面。由于 FastAPI 默认支持静态文件我们需要把static/index.html挂载到根路径下。# 文件路径ghost_conductor/main.py from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from engine import StoryEngine from scenario import build_scenario app FastAPI(title幽灵列车长推理系统) engine StoryEngine(build_scenario()) class InteractRequest(BaseModel): user_input: str class InteractResponse(BaseModel): node_id: str text: str is_end: bool available_actions: list[str] app.post(/api/interact, response_modelInteractResponse) def interact(req: InteractRequest): 接收玩家输入返回剧情响应 if not req.user_input.strip(): return InteractResponse( node_idengine.player.current_node, textengine.get_current_node().text, is_endFalse, available_actionsengine.get_available_actions(), ) # 如果游戏已经结束输入“重新开始”可以重置 current_node engine.get_current_node() if not current_node.options and 重新开始 in req.user_input: engine.restart() node engine.get_current_node() return InteractResponse( node_idnode.node_id, textnode.text, is_endFalse, available_actionsengine.get_available_actions(), ) node, message, is_end engine.parse_input(req.user_input) return InteractResponse( node_idnode.node_id, textmessage, is_endis_end, available_actionsengine.get_available_actions(), ) app.post(/api/restart) def restart_game(): 重置游戏状态 engine.restart() node engine.get_current_node() return InteractResponse( node_idnode.node_id, textnode.text, is_endFalse, available_actionsengine.get_available_actions(), ) # 静态文件前端页面 app.mount(/static, StaticFiles(directorystatic), namestatic) app.get(/) def index(): return FileResponse(static/index.html)这个接口层有一个很关键的工程点engine是全局单例。对于本地 Demo 来说这没问题。但如果要部署到生产环境全局单例意味着所有玩家共享一份游戏状态显然不行。更合理的做法是使用session_id区分不同玩家把PlayerState存储到 Redis 或内存字典里。这个我会在第 6 节展开讲。4.5 编写前端聊天页面 static/index.html前端页面不需要复杂框架一个简单的聊天界面即可。它通过fetch调用/api/interact把用户输入发送给后端并把返回的剧情文本追加到页面上。!-- 文件路径ghost_conductor/static/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / title至冬公共列车 - 幽灵列车长推理/title style body { font-family: Microsoft YaHei, sans-serif; background: #1a1a2e; color: #eee; max-width: 700px; margin: 30px auto; padding: 0 20px; } #chat { background: #16213e; border-radius: 12px; padding: 20px; height: 400px; overflow-y: auto; } .message { margin: 12px 0; } .player { text-align: right; } .player span { background: #0f3460; padding: 8px 14px; border-radius: 16px; display: inline-block; } .npc { text-align: left; } .npc span { background: #533483; padding: 8px 14px; border-radius: 16px; display: inline-block; white-space: pre-wrap; } #input-area { display: flex; margin-top: 16px; } #user-input { flex: 1; padding: 10px; border-radius: 8px; border: none; } #send-btn { margin-left: 10px; padding: 10px 18px; border: none; border-radius: 8px; background: #e94560; color: #fff; cursor: pointer; } /style /head body h2 至冬公共列车/h2 div idchat/div div idinput-area input typetext iduser-input placeholder输入你的行动或问题... / button idsend-btn发送/button /div script const chatDiv document.getElementById(chat); const input document.getElementById(user-input); const sendBtn document.getElementById(send-btn); function appendMessage(text, sender) { const div document.createElement(div); div.className message sender; const span document.createElement(span); span.textContent text; div.appendChild(span); chatDiv.appendChild(div); chatDiv.scrollTop chatDiv.scrollHeight; } async function sendMessage(text) { const response await fetch(/api/interact, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ user_input: text }) }); const data await response.json(); appendMessage(data.text, npc); if (data.is_end) { appendMessage(输入“重新开始”可以再玩一次。, npc); } } sendBtn.addEventListener(click, async () { const text input.value.trim(); if (!text) return; appendMessage(text, player); input.value ; await sendMessage(text); }); input.addEventListener(keydown, (e) { if (e.key Enter) { sendBtn.click(); } }); // 初始化欢迎信息 fetch(/api/restart, { method: POST }) .then((res) res.json()) .then((data) { appendMessage(data.text, npc); }); /script /body /html4.6 运行与验证启动服务uvicorn main:app --reload --port 8000打开浏览器访问http://127.0.0.1:8000你应该能看到一个简单的聊天页面并收到一段列车场景的描述。在输入框里依次输入以下内容测试主线输入“询问列车长”触发查票剧情输入“查看车厢镜子”进入镜面场景输入“捡起日志”进入日志场景输入“回去找列车长”进入最终推理输入“他不是鬼”触发好结局。如果你没有集齐线索直接输入“他不是鬼”系统会停留在当前节点同时提示你还需要更多信息。原因在engine.py的条件判断里condition_flag不存在时continue会跳过这个选项。预期输出大致如下你登上至冬公共列车车厢里灯光昏暗……4.7 结果说明这个 Demo 的核心价值不在于“猜谜”而在于它展示了一套完整的交互叙事实现方式。你可以在scenario.py中增加任意数量的节点修改关键词表甚至把结局从 2 个扩展到 5 个而不需要修改engine.py和main.py的主要结构。你可以把剧本替换成任何题材比如“旧校舍的守门人”、“深夜便利店的顾客”、“太空站的异常信号”只要节点之间构成有向图引擎就能跑起来。5. 常见问题与排查思路实际运行过程中你可能会遇到下面这些问题。我按“问题现象、常见原因、解决思路”的方式整理成表格。问题现象常见原因解决思路启动时提示ModuleNotFoundError: No module named fastapi当前虚拟环境未激活或依赖未安装先执行source venv/bin/activate再执行pip install -r requirements.txt访问http://127.0.0.1:8000提示 404当前目录不是项目根目录或static目录不存在确认在ghost_conductor目录下运行uvicorn main:app --reload --port 8000并确认static/index.html存在输入关键词后剧情没有跳转用户输入的文字没有命中keywords列表查看当前节点的options关键词输入更明确的词例如“查看镜子”而不是“那边有什么”已经集齐线索但无法选择“他不是鬼”condition_flag设置不正确确认在journal节点中“回去找列车长”的add_flag是has_read_journal且mirror节点中“捡起日志”的add_flag是has_seen_mirror全局单例导致多个用户互相干扰engine定义在模块级别所有玩家共用同一个状态改用session_id区分玩家状态把状态存到内存字典或 Redis输入中文出现乱码控制台编码问题或 HTML 没有声明 UTF-8确保文件保存为 UTF-8HTML 中已有meta charsetUTF-8 /前端发送请求后没有响应后端服务没有启动或端口被占用查看终端日志确认 8000 端口未被占用可以执行lsof -i:8000查看占用情况6. 最佳实践与工程建议6.1 用 session_id 区分玩家如果你要把这个 Demo 部署到服务器多用户状态下全局单例是不行的。最简单的改进是为每个会话创建独立引擎实例并把状态保存到内存字典里from engine import StoryEngine engines: dict[str, StoryEngine] {} def get_engine(session_id: str) - StoryEngine: if session_id not in engines: engines[session_id] StoryEngine(build_scenario()) return engines[session_id]后续可以在InteractRequest中增加session_id字段。生产环境建议把状态写入 Redis并设置过期时间防止内存无限增长。6.2 把剧本改成 JSON 或 YAML目前剧本写在 Python 里优点是类型检查方便但缺点是非技术人员改起来困难。在实际项目中更推荐把剧本抽成 JSON 或 YAML 文件方便策划同学维护。例如scenario.json的结构可以是{ start: { text: ..., options: [ { action: 询问列车长, keywords: [列车长, 询问], next: ticket_check } ] } }然后在scenario.py中读取文件并构建节点对象。这样改剧情不需要懂 Python。6.3 输入解析升级策略关键词匹配只是最基础的方案。如果剧情复杂建议分阶段升级第一阶段关键词匹配第二阶段同义词表 停用词过滤例如“查看日记”“翻开日志”都映射到read_journal第三阶段借助正则表达式抽取动作和对象第四阶段接入大语言模型进行意图分类但最终跳转仍由状态机控制。升级时要注意每一步都要保证旧流程仍然可测试。你可以为每个节点编写单元测试输入固定文本断言跳转结果。6.4 日志与调试开发这类交互系统最重要的一条经验是记录玩家路径。你可以用标准库logging记录每次交互import logging logging.basicConfig(levellogging.INFO) logging.info( user_input%s, current_node%s, next_node%s, user_input, engine.player.current_node, next_node_id )当玩家反馈“某个结局触发不了”时日志能快速帮你还原现场。6.5 安全边界与合法授权如果你的系统需要接入用户登录、数据库操作或外部 AI 接口请务必注意不要收集与剧情无关的个人敏感信息对话内容如果存储需要明确告知用户并取得授权部署到公网时控制好访问频率防止被恶意刷接口在测试环境充分验证后再发布避免未完成的剧情分支暴露给真实用户。即使是练手项目也要养成“默认最小授权、异常不泄露内部信息”的开发习惯。7. 总结与后续扩展通过这个“至冬公共列车的列车长是鬼”的实战项目我们实现了一个完整的推理剧情系统。核心内容包括用StoryNode和scenario.py管理剧情节点用StoryEngine实现状态跳转和关键词解析用 FastAPI 提供 Web API用简单的 HTML 页面完成交互用flags机制实现线索收集与结局条件判断。如果你希望继续深入可以从几个方向扩展把剧本迁移到 JSON 文件做成可视化剧情编辑器增加更多结局分支让选择更自由接入语音识别把“输入文本”变成“说出的话”更贴近“和列车长对话”的感觉增加分数系统根据玩家收集线索的数量和速度给出不同评价把单个乘客的推理故事扩展成多人协作解谜每个玩家看到的信息不同。下次再看到“XX是鬼”这种标题的需求除了脑补剧情你还可以多问一句“这个剧情是怎么推进的玩家有哪些证据能改变结局”当你开始这样思考时你已经不是在写一个灵异段子而是在设计一套交互系统。动手跑一遍代码把结局改成“他实际上是一个老旧的列车机器人”然后又会出现全新的推理路径。这个小小的改动会让你更理解为什么状态机比自由对话更适合这类剧情游戏。如果你照着文章搭建过程遇到了问题可以对照第 5 节的排查表格逐项检查。也欢迎在评论区留言交流你的剧情节点设计思路。