公司动态
Python实现末日逃生列车:从文案到命令行文字冒险游戏
当你在标题里看到“末日逃生列车”“选择你的专属无限续美食车厢”时第一反应可能是一个互动文案或者一个游戏策划选题。但如果换个角度把这句话交给程序员问题立刻就变了我要用什么样的数据结构和逻辑才能让玩家在一个命令行窗口里完成“选择车厢 → 领取食物 → 继续前进 → 活下去”的完整循环我给出的判断是这个主题真正值得动手的地方不在于“无限续餐”的文案想象力而在于它恰好覆盖了互动叙事游戏最核心的“事件驱动状态流转”。用 Python 写一个可运行的文字冒险游戏不需要图形引擎也不需要 Web 框架100 行左右的标准库代码就能跑通。但其中包含的有限状态机、回合制资源管理、配置与逻辑分离这几个思路却可以迁移到 Web 游戏、聊天机器人甚至是 Agent 对话流的底层设计里。这篇文章会从“如何把一句话策划案落地成代码”的角度展开。我会先拆解这个游戏的核心机制然后给出完整的 JSON 配置文件和 Python 主程序带着你把“末日逃生列车”做成一个能在终端里运行、能存档、能扩展的小项目。文章也会覆盖运行验证、常见报错和最佳实践方便你照着写一遍后在这个基础上做出自己的版本。1. 这个项目到底难在哪里很多人在第一次看到这类互动游戏主题时会误以为难的是“内容创意”——比如要想出多少个车厢、多少种食物、多少段末日剧情。但从工程角度看创意只是最外围的部分真正的复杂度集中在三个地方第一玩家的当前位置和游戏进度如何表达。玩家在“动力车厢”“美食车厢”“休息车厢”“驾驶室”之间移动每一次选择都会改变下一回合的界面。如果不用状态机的思路去管理而是把所有逻辑堆在 if-else 里场景一多就会失控。第二“无限续餐”这个机制怎么实现才不失控。如果“无限”是没有任何约束的玩家只需要反复点击领取食物游戏就失去了趣味。更合理的做法是给“无限”增加代价每次领取消耗一定时间每天跨过 24 点会消耗存量食物。这样“无限”就变成了“有限回合里的高性价比补给”玩家必须权衡是继续收集食物还是尽快推进剧情。第三内容文本和程序逻辑如何解耦。车厢名称、描述、动作、随机事件这些内容如果硬编码在代码里每一次调整都要修改 Python 文件非常不利于迭代。更好的做法是单独维护一份 JSON 配置程序只负责“按配置驱动运行”。理解了这三点这个项目就不再是一个简单的文案题而是一个小型的互动游戏引擎练习。它适合三部分人刚学完 Python 基础、想找一个小项目练习面向对象和 JSON 处理的读者对互动叙事游戏感兴趣、想验证自己策划想法的玩家以及需要给团队做技术 Demo、演示“配置驱动”思想的开发者。2. 核心概念与设计原理2.1 有限状态机玩家其实停留在某个节点“有限状态机”这六个字听起来像个抽象术语但它描述的就是一个非常直观的现象在任意时刻玩家只处于一个确定的“状态”可以从一个状态迁移到另一个状态。放到这个游戏里状态就是玩家当前所在的车厢。每节车厢是状态集合中的一个节点玩家在节点内可以执行若干动作每个动作要么把玩家转移到另一个节点要么触发一段数值变化后仍然留在当前节点。整辆列车本质上就是一张节点地图。反过来看如果不用状态机而是用自然语言写逻辑很容易出现下面的伪代码如果玩家在动力车厢并且输入了1那么进入美食车厢 如果玩家在美食车厢并且输入了1那么食物加五 如果玩家在动力车厢并且输入了2那么进入休息车厢这种写法在四个节点时还能忍受一旦增加到十几个车厢、几十个动作会发现两个典型问题一是每个动作都要单独判断“玩家当前在哪”相同的转移逻辑会被重复写很多遍二是有时候只是改一个车厢的描述却要翻遍代码找到对应字符串。状态机的价值就是把这些逻辑收敛成“当前状态 动作列表”的数据让代码结构变得一致。2.2 回合与时间给“无限续餐”加上约束“无限续餐”是这个游戏最有趣的设计点。如果把它做成“无限点击、无限加食物”玩家会很快失去兴趣因为策略不存在了。更合理的设计是领取食物是有次数无上限的但领取动作消耗时间时间又会触发跨日结算迫使玩家在“囤积补给”和“快速推进”之间做取舍。具体规则可以这样定每次“领取一份食物”消耗 1 小时食物增加 3 份。每次“领取大份食物”消耗 3 小时食物增加 8 份。每次行动都会累计时间累加到 24 小时时进入下一天。每进入新的一天玩家要消耗 1 份食物。如果食物为 0生命值会下降。这个机制把这个游戏从一个“无限资源点击器”变成一个有节奏的生存模拟你可以在美食车厢无限领取但每一次点击都在消耗“时间”这个隐藏货币。时间越晚随机事件越可能到来你的生命值压力也越大。2.3 内容配置为什么用 JSON 而不是硬编码如果你只是做一个给自己玩的小脚本把车厢和动作直接写在 Python 里当然可以。但一旦想快速调整文案、平衡数值或者让策划同学也参与编辑就必须把内容与逻辑分开。JSON 的优点非常契合这个场景结构清晰、层级直观、Python 标准库自带解析能力。用 JSON 保存车厢信息和动作效果主程序只负责“读取配置 → 展示当前状态 → 接收选择 → 执行效果 → 更新状态”内容的增删改就完全不需要动代码。这个“配置驱动”的思路在真实项目里也常见游戏表驱动、工作流定义、爬虫规则配置、AI Agent 的工具定义本质上都是同一个套路。3. 环境准备与项目结构作为一个标准库实现的命令行项目这个游戏对环境要求非常低Python 3.8 及以上版本主要用到了标准库 json、random、os、sys。不需要安装任何第三方依赖。支持 Windows、macOS、Linux 终端运行。建议使用 UTF-8 编码保存代码和 JSON 文件避免中文乱码。操作系统和 Python 版本请以你本地的实际环境为准本文演示的是通用实现思路不依赖某个特定版本的新特性。项目目录结构建议如下doomsday_train/ ├── game_data.json └── train_game.pygame_data.json负责描述列车世界包括玩家初始属性、车厢列表、动作效果和随机事件。train_game.py负责加载配置、驱动游戏循环、处理玩家输入。两者分离后后续扩展新车厢时通常只需要修改 JSON 文件。创建目录和文件可以使用下面的命令mkdir doomsday_train cd doomsday_train touch game_data.json train_game.py在 Windows 环境也可以直接通过 IDE 新建普通文本文件保存时选择 UTF-8 编码即可。4. 配置文件设计先定义“列车世界”配置文件的地位是“列车世界的数据字典”。我们把它拆成 player、carriages、random_events 三个节点来设计。player 定义幸存者的初始状态包括名字、生命值、初始食物、初始所在车厢。carriages 定义一个车厢数组每个车厢包含 id、名称、描述、可选动作列表。random_events 是可选的随机事件集合用于增加每局游戏的不确定性。下面是完整的game_data.json内容{ player: { name: 流浪者, hp: 100, food: 20, position: engine_room }, carriages: [ { id: engine_room, name: 动力车厢, desc: 列车发动机的轰鸣声让你稍微安心窗外是一片灰色的废墟。, actions: [ { text: 前往美食车厢, next: dining_car, cost: 1 }, { text: 前往休息车厢, next: sleeping_car, cost: 1 }, { text: 前往驾驶室查看前方路况, next: driver_room, cost: 1 } ] }, { id: dining_car, name: 美食车厢无限续, desc: 这里就是传说中的专属无限续美食车厢食物补给机闪着绿灯。, actions: [ { text: 领取一份食物, effect: gain_food, value: 3, cost: 1 }, { text: 领取一份大份食物, effect: gain_food, value: 8, cost: 3 }, { text: 返回动力车厢, next: engine_room, cost: 1 } ] }, { id: sleeping_car, name: 休息车厢, desc: 列车卧铺区你可以在这里恢复体力。, actions: [ { text: 休息2小时恢复生命值, effect: heal, value: 15, cost: 2 }, { text: 返回动力车厢, next: engine_room, cost: 1 } ] }, { id: driver_room, name: 驾驶室, desc: 列车长已经不知去向仪表盘上有一个红色的按钮。, actions: [ { text: 按下红色按钮, effect: win }, { text: 返回动力车厢, next: engine_room, cost: 1 } ] } ], random_events: [ { type: food_bug, desc: 补给机指示灯闪烁吐出了一份额外的食物。, value: 2 }, { type: hp_loss, desc: 列车突然剧烈晃动你撞到了车厢门框。, value: 10 } ] }配置里出现了一个关键结构每个 action 都有 text分别解释动作文案然后根据场景选择配置 next、effect、cost 字段。next 表示跳转到另一个车厢的 ideffect 表示执行数值效果cost 表示该动作消耗的小时数。这里真正容易踩坑的地方是 JSON 的语法检查。JSON 不支持注释字符串必须使用双引号最后一个字段后面不能有多余逗号。如果文件内容写错了程序启动时就会在 json.load 这一步报错。5. 主程序实现Python 状态循环在配置文件设计好之后主程序的任务就变成了一件非常简单的事循环展示当前车厢信息接收玩家输入执行对应动作更新状态。我建议把代码分成三块来理解配置加载与初始化、界面展示、动作执行与游戏循环。下面会分段给出代码你可以按顺序拼到同一个train_game.py文件中。5.1 配置加载与初始化这一部分负责读取 JSON 文件并构建游戏对象。这里使用一个 Game 类来管理玩家、车厢字典、随机事件等字段而不是把全部变量散落在模块级。# train_game.py 第一部分配置加载与初始化 import json import os import random import sys def load_config(path): with open(path, r, encodingutf-8) as f: return json.load(f) class Game: def __init__(self, config): self.player { name: config[player][name], hp: config[player][hp], max_hp: config[player][hp], food: config[player][food], day: 1, hour: 0, position: config[player][position], } self.carriages {carriage[id]: carriage for carriage in config[carriages]} self.events config.get(random_events, []) self.game_over False def get_current_carriage(self): return self.carriages[self.player[position]]把车厢数组转成字典是一个值得记住的小技巧。config 里的 carriages 是列表但我们需要频繁按 id 查找车厢字典可以做到 O(1) 查询。player 使用普通字典而不是类对象是因为这个项目的字段不多字典可以直接序列化为存档文件写起来更省事。5.2 界面展示这一部分负责输出状态栏、当前车厢描述和可执行动作列表。输出格式不需要花哨但要保证玩家一眼能看清当前第几天、几点、生命值和食物数量。def show_status(self): p self.player print( * 50) print(f第 {p[day]} 天 {p[hour]:02d}:00 | 生命值 {p[hp]}/{p[max_hp]} | 食物 {p[food]}) print( * 50) def show_carriage(self): carriage self.get_current_carriage() print(carriage[name]) print(carriage[desc]) def show_actions(self): carriage self.get_current_carriage() for i, action in enumerate(carriage[actions], 1): cost action.get(cost, 0) if cost: print(f{i}. {action[text]}消耗 {cost} 小时) else: print(f{i}. {action[text]})Python 的格式化语法里{p[hour]:02d}表示把 hour 按整数格式输出不足两位时补零。这样第 3 小时会显示为03:00状态栏看起来更整齐。界面展示的三个方法只负责输出不修改任何数据职责单一。这是工程上推荐的做法把“显示”和“逻辑”分开。5.3 动作执行与游戏循环动作执行是程序的核心。玩家输入一个数字后程序需要先判断数字是否合法然后根据 action 的字段执行跳转、加食物、回血、胜利等效果。def advance_time(self, hours): p self.player p[hour] hours while p[hour] 24: p[hour] - 24 p[day] 1 p[food] - 1 if p[food] 0: p[food] 0 p[hp] - 10 print(因为食物耗尽你的生命值下降了 10 点) if p[hp] 0: self.game_over True print(你饿死在了逃生列车上……游戏结束) def do_action(self, index): carriage self.get_current_carriage() actions carriage[actions] if index 1 or index len(actions): print(没有这个选项请重新输入。) return action actions[index - 1] cost action.get(cost, 0) self.advance_time(cost) effect action.get(effect) if effect gain_food: value action.get(value, 1) self.player[food] value print(f你领取了 {value} 份食物补给机依然在正常运转。) elif effect heal: p self.player old_hp p[hp] p[hp] min(p[max_hp], p[hp] action.get(value, 0)) print(f你休息了一会儿生命值从 {old_hp} 恢复到 {p[hp]}。) elif effect win: print(你按下红色按钮列车紧急制动的红灯亮起前方竟然出现了幸存者基地) self.game_over True elif action.get(next): self.player[position] action[next] print(f你前往{self.carriages[action[next]][name]}) else: print(f你执行了{action[text]}) self.check_random_event() def check_random_event(self): if not self.events: return if random.random() 0.25: event random.choice(self.events) if event[type] food_bug: self.player[food] event.get(value, 0) print(f[随机事件] {event[desc]} 食物 {event.get(value, 0)}) elif event[type] hp_loss: self.player[hp] - event.get(value, 0) print(f[随机事件] {event[desc]} 生命值 -{event.get(value, 0)}) if self.player[hp] 0: self.game_over True print(你在列车上倒下了……游戏结束) def check_win(self): if self.player[day] 5: self.game_over True print(你成功在列车上生存了 5 天远处出现了幸存者营地的灯光) return self.game_over def run(self): print(f欢迎登上末日逃生列车{self.player[name]}。) print(你可以选择不同的车厢在无限续美食车厢中领取食物但要小心时间流逝。) while not self.game_over: self.show_status() self.show_carriage() self.show_actions() raw input(请输入选项编号).strip() if not raw.isdigit(): print(请输入数字编号。) continue self.do_action(int(raw)) if self.check_win(): break print(游戏结束。感谢参与《末日逃生列车专属无限续美食车厢》。)这段代码里最值得解释的是advance_time和check_random_event的位置关系。advance_time在动作效果之前执行意味着时间流逝会先发生然后才是动作反馈。比如你在第 23 小时选择休息 2 小时时间结算后直接进入新的一天并扣除 1 份食物。此时如果食物为 0生命值会先下降然后才轮到休息动作恢复生命值。这种“先扣后补”的顺序会让玩家在做选择时更有紧张感。随机事件也有一个特别注意点它只在玩家执行动作后触发概率为 25%。如果玩家连续几次遇到事件也没有问题因为事件本身并不复杂。如果后续想增加更复杂的事件条件比如“食物大于 20 时触发某个事件”可以在 check_random_event 里增加条件判断。5.4 启动入口最后加上标准的入口代码让脚本可以通过命令行参数指定配置文件路径if __name__ __main__: config_path game_data.json if len(sys.argv) 1: config_path sys.argv[1] if not os.path.exists(config_path): print(f配置文件 {config_path} 不存在。) sys.exit(1) game_config load_config(config_path) Game(game_config).run()默认使用当前目录下的game_data.json也可以通过python train_game.py 另一份配置.json的方式加载其他配置。这个设计为以后做多情景版本留下了余地。6. 运行结果与功能验证代码写完并确认game_data.json和train_game.py处于同一目录后在终端执行python train_game.py程序启动后会先打印欢迎语和状态栏然后展示当前车厢的描述和可用动作。下面是一次模拟运行的输出示例欢迎登上末日逃生列车流浪者。 你可以选择不同的车厢在无限续美食车厢中领取食物但要小心时间流逝。 第 1 天 00:00 | 生命值 100/100 | 食物 20 动力车厢 列车发动机的轰鸣声让你稍微安心窗外是一片灰色的废墟。 1. 前往美食车厢消耗 1 小时 2. 前往休息车厢消耗 1 小时 3. 前往驾驶室查看前方路况消耗 1 小时 请输入选项编号如果选择 1进入美食车厢后选择“领取一份食物”输出会变成类似 第 1 天 01:00 | 生命值 100/100 | 食物 23 美食车厢无限续 这里就是传说中的专属无限续美食车厢食物补给机闪着绿灯。 1. 领取一份食物消耗 1 小时 2. 领取一份大份食物消耗 3 小时 3. 返回动力车厢消耗 1 小时 请输入选项编号验证是否成功可以重点看四点选择数字是否能正确跳转到对应车厢。领取食物后食物数量是否按 value 增加时间是否按 cost 增加。时间累计超过 24 小时后是否进入第二天并且食物减少 1。随机事件是否偶尔出现且事件效果正确改变数值。如果你想快速测试“跨日扣食物”的逻辑可以在休息车厢连续休息多次。比如第 23 小时休息 2 小时程序会自动处理跨日。这是验证时间系统最直接的方式。7. 常见问题与排查思路项目虽然不大但实际运行时仍有一些容易踩的坑。下面整理了一张排查表方便你快速定位问题。问题现象可能原因排查方式解决方案启动时提示 JSONDecodeErrorJSON 文件格式错误比如多了逗号、字符串用了单引号查看报错中提示的行号用 JSON 在线校验工具检查根据提示行修复 JSON 格式中文乱码文件保存时不是 UTF-8 编码或终端编码不一致用 IDE 查看编码在终端执行python --version后查看输出统一使用 UTF-8 编码保存Windows 终端可尝试切换代码页输入 1 后没有任何变化当前车厢 actions 中的动作没有设置 next 或 effect打印当前动作的字典内容检查 JSON 中该动作配置确保存在 next 或 effect 字段食物数量变成负数跨日扣食物没有做下限保护查看 advance_time 中 food 的处理逻辑让 food 小于 0 时重置为 0并扣除生命值程序一运行就退出game_data.json 与脚本不在同一目录检查启动时是否报文件不存在把脚本和配置放在同一目录或使用命令行参数指定配置路径随机事件频繁触发影响测试事件概率固定为 25%连续触发属于正常随机通过修改代码中 random.random() 的阈值调低概率在 check_random_event 中调整阈值或者临时注释事件检查从经验来看最简单也最容易被忽略的问题是 JSON 文件格式。尤其是从网页或其他编辑器复制配置时很容易混入中文字符的全角符号、多余逗号或者 BOM 头。遇到解析报错时先不要怀疑 Python 代码优先检查 JSON 内容。8. 最佳实践与工程扩展建议8.1 配置校验当配置内容越来越多时建议在加载配置后增加一个简单的校验逻辑而不是等到游戏运行到一半才报错。比如校验每个动作要么配置了 next要么配置了 effect二者至少存在一个校验 next 指向的车厢 id 真实存在。下面是一段可以在 Game.init中调用的轻量校验示例def validate_config(self): for carriage in self.carriages.values(): for action in carriage[actions]: if next not in action and effect not in action: raise ValueError(f车厢 {carriage[id]} 的动作缺少 next 或 effect: {action}) if next in action and action[next] not in self.carriages: raise ValueError(f动作指向了不存在的车厢: {action[next]}) print(配置校验通过。)这段代码只是基础校验。如果项目继续扩大可以引入jsonschema这类库定义配置的 schema实现更严格的类型检查不过这已经超出本文的范围。8.2 存档与回滚文字冒险游戏很适合做存档。由于 player 本身就是一个可 JSON 序列化的字典保存进度非常容易。可以新增一个 save 方法def save(self, pathsave.json): with open(path, w, encodingutf-8) as f: json.dump({player: self.player}, f, ensure_asciiFalse, indent2)再在 run 循环中增加一个隐藏指令比如输入s保存输入q退出。注意要在输入处理中优先判断这些指令而不是强行转成数字。这里需要提醒一个工程细节存档本质上是对当前状态的快照。任何新加的状态字段比如“已触发的剧情标记”“车厢解锁状态”都应该纳入存档结构否则读档后会出现数据丢失。8.3 扩展为 Web 版本如果想让这款游戏更容易分享一个很自然的思路是把它搬到浏览器里。因为配置和逻辑已经分离Web 化的成本其实很低。简单做法是用 FastAPI 写两个接口一个接口返回当前车厢信息、玩家状态和动作列表另一个接口提交玩家选择并返回更新后的状态。前端只需要渲染 JSON 结果。配置文件的格式可以继续沿用game_data.json只是把文件读取改为服务启动时加载到内存。更轻量的做法是直接把game_data.json转换成 JavaScript 对象在前端用同样的状态机逻辑渲染界面。这样不依赖后端玩家打开网页就能玩。8.4 增加更多事件类型当前随机事件只有食物增加和生命损失两种。后续可以扩展出这些效果类型teleport玩家被随机传送到另一节车厢。buff在若干回合内减少动作时间消耗。story输出一段剧情文本不改变任何数值。open_door解锁一个原本不可见的车厢。在动作配置里增加一个conditions字段也能实现“食物大于 20 时才能执行某动作”的条件分支。状态机本身不复杂复杂度主要来自内容和条件组合。9. 总结与后续学习方向从一个文案标题出发我们用 Python 实现了一个完整的命令行互动游戏。这个项目看起来很小但它把互动叙事游戏最核心的循环完整走了一遍配置定义世界、状态展示界面、输入触发动作、时间推进资源变化、随机事件打破单调。跑通这个 Demo 后你对状态机的理解会有一次质的提升而不是停留在书本概念上。下一步你可以先改game_data.json里的数值观察游戏平衡性的变化然后尝试加一个新车厢比如“武器车厢”或“医务车厢”让动作类型更丰富最后再尝试加入存档功能把它变成一款随时可以暂停的生存小游戏。如果想继续深入可以研究游戏开发里的行为树或者去了解聊天机器人、Agent 对话系统中的事件循环设计。这个项目的底层思想和那些看似复杂的系统是相通的状态是可以被描述的行动是会改变状态的而好的内容配置是让代码保持简洁的关键。