如果你已经过了“照着教程打印九九乘法表”的阶段,想找个真正有成就感的小项目练手,那用 Python 做文字冒险游戏是我特别推荐的一个方向。它不需要图形界面、不需要游戏引擎、甚至不需要安装任何第三方库,一个.py文件就能跑起来。但麻雀虽小五脏俱全:分支剧情、背包系统、条件锁、存档读档,这些正经游戏开发里绕不开的东西,它全都能练到。我自己的感觉是,认认真真写完一个文字冒险游戏,你对 Python 函数、字典、JSON、文件操作这些基础知识的掌控力,会比刷一百道语法题都扎实。
1. 项目概述:文字冒险游戏为什么值得用 Python 写一遍
1.1 文字冒险游戏的本质:一个简单的状态机
很多人把文字冒险游戏等同于“古早的网页小游戏”,但换个角度看,它其实是学习编程的最佳练习场之一。一个文字冒险游戏的核心链路非常清晰:游戏世界由若干个场景(Scene)组成,每个场景有一段描述文本,以及数个选项(Choice)。玩家做出选择后,游戏根据选择携带的条件、效果和跳转目标,进入下一个场景。这个循环一直持续到走到结局节点。
这条链路如果抽象成技术术语,就是一个典型的状态机:场景是状态,选择是事件,事件的执行结果决定下一个状态。你别小看这个模型,现代游戏里的任务系统、对话框系统、关卡流程,底层几乎都是同一套思路。所以当你用 Python 把文字冒险游戏跑通,你实际上是在用最小的成本练习一套通用的状态管理思想,以后再去理解图形界面游戏或 Web 应用里的流程控制,会轻松得多。
1.2 技术选型:为什么纯标准库就够了
我见过不少新手一上来就想着用 Pygame、Tkinter 做图形界面,结果光环境配置和研究事件循环就花掉三天,游戏本身反而没写几行。文字冒险游戏的核心是“文字 + 逻辑”,用纯标准库反而更聚焦:print()负责输出场景文本,input()负责读取玩家指令,dict和list负责承载游戏数据,json负责存档读档,os负责文件路径处理。这一套组合完全够用,还能让初学者把精力集中在“游戏逻辑”而不是“框架 API”上。
从工程角度看,纯标准库还有一个巨大优势:跨平台、零依赖。你的游戏脚本在任何一台装了 Python 3 的电脑上都能直接运行,不需要pip install任何东西。对于后续想把游戏分享给朋友玩的情况,依赖越少,打包成 exe 的坑就越少。这也是我在教学项目中一贯的思路:能用标准库解决的,绝不让新手碰第三方库。
1.3 先做一个两个节点的最小原型
在讨论完整架构之前,我建议先写出一个能跑的最小原型,感受一下文字冒险游戏的手感。这个原型只包含一个场景和一个选项:
print("你站在一扇门前。") print("1. 推开门") cmd = input("> ") if cmd == "1": print("门开了,你走了出去。") print("—— 完 ——") else: print("你犹豫了一下,什么都没发生。")这段代码虽然简陋,但它已经包含了文字冒险游戏最核心的三件事:输出场景描述、读取玩家输入、根据输入改变结果。你需要做的,就是把这个 20 行的原型,扩展成一个通过字典驱动、支持多场景多分支的“游戏引擎”。这也是我在后面章节里一步步展开的内容。
2. 环境准备:20 分钟配好 Python 与 VSCode 开发环境
2.1 Windows 下 Python 安装与环境变量配置
如果你刚接触 Python,我建议先确认一下电脑里到底装没装 Python。打开终端(Windows 下按Win + R,输入cmd回车),执行:
python --version如果显示Python 3.10.x之类的版本号,说明已经装好了。如果提示“不是内部或外部命令”,那就要自己装一次。安装本身很简单,去 Python 官网下载对应系统的安装包,但新手最容易翻车的点不是安装,而是安装时忘了勾选Add Python to PATH。这一步非常关键,它决定了你能不能直接在终端里用python命令启动解释器。当年我教朋友装 Python,十个人里有三个都栽在这,安装包一路点“下一步”,最后打开终端才发现python命令根本识别不了。
安装完成后,建议重新打开一个终端窗口,再次执行python --version验证。如果依然提示找不到命令,可以手动检查环境变量:在系统设置里搜索“环境变量”,把 Python 安装路径和其下的Scripts目录加到Path中。Scripts目录尤其重要,因为后续用pip安装工具时,可执行文件默认会装在那里。配置好之后,再执行pip --version确认包管理工具可用。
2.2 编辑器选型与 VSCode 中的 Python 环境配置
编辑器这块,我一般不推荐新手直接上 IDE,而是用 VSCode。不是因为 IDE 不好,而是 VSCode 更轻、更灵活,而且后续写爬虫、写脚本、写 Web 项目都能复用同一套环境。去 VSCode 官网下载安装后,打开扩展面板,搜索并安装两个扩展:Python(微软官方出的那个)和Pylance。
安装完扩展,还需要做一步关键操作:按Ctrl + Shift + P打开命令面板,输入Python: Select Interpreter,选择你刚才安装的 Python 解释器。这个步骤没做好的话,VSCode 虽然能写代码,但无法提供代码提示和语法检查,相当于白装了扩展。之后创建项目文件夹,新建一个game.py,VSCode 右下角就会显示 Python 版本号,说明环境已经正确关联。
终端配置方面,VSCode 默认的终端在 Windows 下可能是 PowerShell,直接打开后执行python --version验证一下。如果之前配置过多个 Python 版本(Python 2 和 Python 3 共存,或者装了 Anaconda),这里要特别留意解释器选择,不然很容易出现“终端里用的 Python 和 VSCode 里跑的 Python 不是同一个”的情况,这种问题排查起来很磨人。
2.3 跑通第一个脚本并验证 pip
环境配好后,在game.py里输入最简单的一行:
print("hello adventure")然后在 VSCode 里右键选择“在终端中运行 Python 文件”,或者直接打开终端执行python game.py。如果终端打印出hello adventure,说明整条链路已经通了。
为什么要单独强调“跑通第一个脚本”?因为我见过太多人把时间花在研究各种概念上,结果连一个基础脚本都没运行过。实际开发的时候,你写的代码大概率不是一次通过,需要在无数次“运行、报错、修改、再运行”的循环里推进。所以从第一分钟开始,就把“能否在终端里运行 Python 文件”当作环境配置成功的唯一标准,这比看再多安装教程都管用。
3. 核心代码实现:从故事结构到可运行的游戏引擎
3.1 用字典描述游戏世界:场景、选项、条件与效果
完整的文字冒险游戏,故事数据量会很大,如果全用if-else写在代码里,写到后面会疯掉。更合理的方案是把游戏世界抽象成数据,用字典承载一切,代码只负责“解释”这些数据。我先放一个完整的故事数据结构,以“深夜实验室”为主题,包含场景、选项、道具要求和剧情标记。
STORY = { "title": "深夜实验室", "start": "wake_up", "scenes": { "wake_up": { "text": "你在一间陌生的实验室里醒来,四周只有实验台、一张纸条和一扇紧闭的门。", "choices": [ {"text": "查看实验台上的纸条", "next": "read_note"}, {"text": "回想纸条内容", "next": "recall_note", "need_flag": "note_read"}, {"text": "检查门上有没有线索", "next": "check_door"}, {"text": "搜索房间的其他角落", "next": "search_room"} ] }, "read_note": { "text": "纸条上写着:『离开的密码是墙上的数字。钥匙在抽屉里。』", "set_flag": ["note_read"], "choices": [ {"text": "返回", "next": "wake_up"} ] }, "recall_note": { "text": "你仔细回想,纸条上的字浮现在眼前:『密码是墙上的数字,钥匙在抽屉里。』", "choices": [ {"text": "返回", "next": "wake_up"} ] }, "search_room": { "text": "你在抽屉里摸到一把冰冷的钥匙。", "choices": [ {"text": "拿起钥匙", "next": "take_key", "add_item": ["实验室钥匙"]}, {"text": "先不拿,返回", "next": "wake_up"} ] }, "take_key": { "text": "你收好钥匙,回到房间中央。", "choices": [ {"text": "返回", "next": "wake_up"} ] }, "check_door": { "text": "门上有一个四位数字密码锁和一个钥匙孔。门旁边的墙上似乎有字迹。", "choices": [ {"text": "仔细看墙上的字迹", "next": "look_wall"}, {"text": "回头再搜一下房间", "next": "wake_up"} ] }, "look_wall": { "text": "墙上有三个模糊的数字:7、3、9。", "set_flag": ["code_known"], "choices": [ {"text": "返回门口尝试密码", "next": "input_code"}, {"text": "先去别处看看", "next": "wake_up"} ] }, "input_code": { "text": "你在密码锁上按下 7-3-9,锁发出咔哒一声。但门还需要钥匙才能完全打开。", "choices": [ {"text": "用实验室钥匙开门", "next": "escape", "need_item": "实验室钥匙"}, {"text": "先去找钥匙", "next": "wake_up"} ] }, "escape": { "text": "门缓缓打开,清新的空气涌进来。你成功逃脱了!\n—— 完 ——", "choices": [] } } }我来解释一下这个结构的设计意图。每个场景是一个字典,text是玩家看到的描述,choices是当前可执行的选项列表。每个选项的next字段决定跳转到哪个场景,这就是“分支”。add_item表示选择这个选项后玩家会获得某个道具,set_flag表示设置某个剧情标记,need_item和need_flag则是执行该选项的前置条件。这套设计把“条件”和“效果”都塞进了数据里,引擎代码不用为每个剧情单独写逻辑,非常干净。
3.2 游戏引擎主循环:渲染场景、读取输入、执行跳转
有了故事数据,接下来就是写游戏引擎。引擎要做的事情非常固定:根据当前场景名取出场景字典,打印文本和可选项,读取输入,判断输入是数字指令还是功能指令,然后执行对应动作。我先把基础引擎写出来,存档功能后面再加。
import os import json import time class TextAdventure: def __init__(self, story): self.story = story self.current = story.get("start", "start") self.inventory = [] self.flags = set() def is_available(self, choice): """判断一个选项是否满足前置条件""" need_item = choice.get("need_item") if need_item and need_item not in self.inventory: return False need_flag = choice.get("need_flag") if need_flag and need_flag not in self.flags: return False return True def handle_choice(self, choice): """执行一个选项的效果并跳转""" for item in choice.get("add_item", []): if item not in self.inventory: self.inventory.append(item) print(f"[获得道具] {item}") for flag in choice.get("set_flag", []): self.flags.add(flag) self.current = choice["next"] def render_scene(self): """渲染当前场景""" scene = self.story["scenes"][self.current] print("\n" + "=" * 46) print(scene["text"]) choices = scene.get("choices", []) if not choices: print("【故事到这里结束】") return False for i, c in enumerate(choices, 1): if self.is_available(c): print(f" {i}. {c['text']}") else: print(f" {i}. {c['text']}(条件未满足)") return True def run(self): print("=" * 46) print(" " + self.story["title"]) print("=" * 46) print("输入数字选择分支,输入 inventory 查看背包,输入 quit 退出。\n") while True: is_continue = self.render_scene() if not is_continue: break scenes = self.story["scenes"] choices = scenes[self.current].get("choices", []) cmd = input("> ").strip().lower() if cmd in ("quit", "q"): print("感谢游玩,再见!") break elif cmd == "inventory": if self.inventory: print("背包:" + "、".join(self.inventory)) else: print("背包空空如也。") elif cmd.isdigit(): idx = int(cmd) - 1 if 0 <= idx < len(choices): if self.is_available(choices[idx]): self.handle_choice(choices[idx]) else: print("这个选项目前不可用,先做点别的吧。") else: print("无效选项,请输入列表中的数字。") else: print("无法识别的指令。") if __name__ == "__main__": game = TextAdventure(STORY) game.run()这段代码是整个项目的核心。我挑几个关键点讲讲为什么这么写。
首先,is_available和handle_choice被拆成了独立方法。这样做的目的是让“判断能不能选”和“选中后做什么”彻底分离,后续增加新的条件类型(比如需要同时拥有两件道具)时,只需要改is_available,不会牵连到其他逻辑。
其次,主循环里把render_scene和输入处理分开。每次循环先渲染当前场景,再读取输入,然后执行跳转。这种“渲染-输入-更新”的循环结构,和图形游戏里的游戏循环本质相同,只是把“渲染画面”换成了“打印文本”,理解了这个模式,以后写带界面的项目也不会觉得陌生。
最后,对数字选项的处理用了isdigit()先判断,再转int。这个细节能避免用户输入abc时直接触发int()的异常导致程序崩溃。新手写项目经常忽略这类边界输入,但游戏这种程序,用户乱输是常态,异常处理必须做在前头。
3.3 升级输入解析:从数字选项到关键词指令
数字选项的交互模式非常适合入门,但做多了你会发现,老式的文字冒险游戏其实更倾向于“自由输入”。玩家可以输入open door、take key、look wall这类指令,系统通过关键词识别意图。这种玩法更接近早期交互式小说的体验,而且写起来也不复杂。
我建议在掌握数字选项后,做一个简单的关键词解析器作为进阶练习。基本思路是:输入一行文本,先用正则表达式匹配指令模式,再根据匹配结果执行对应的动作。比如:
import re def handle_free_input(self, text): """简易自由文本指令解析器""" if re.search(r"查看.*纸条|读.*纸条", text): self.current = "read_note" elif re.search(r"拿.*钥匙|捡.*钥匙|take key", text): if "实验室钥匙" not in self.inventory: self.inventory.append("实验室钥匙") print("[获得道具] 实验室钥匙") elif re.search(r"看.*墙|检查.*墙", text): self.current = "look_wall" elif re.search(r"打开.*门|开门|open door", text): if "实验室钥匙" in self.inventory: self.current = "escape" else: print("门锁着,需要钥匙。") else: print("我看不懂你在说什么。")这种解析方式其实很像爬虫里对 HTML 文本做正则匹配,思路是共通的。对于想做完整文字冒险游戏的人,我的建议是:数字选项用来保证“一定能通关”,自由输入用来增加探索感,二者可以并存。比如玩家既可以直接输入数字,也可以输入自然语言指令,两个通道都映射到同一个动作函数。不过这个扩展会明显增加代码复杂度,建议先把基础版跑熟再加。
4. 功能扩展与体验优化:存档、背包与演出效果
4.1 用 JSON 文件实现存档与读档
文字冒险游戏动辄几十分钟流程,如果没有存档功能,玩家每次打开都得从头玩,体验很差。好在 Python 标准库的json模块处理这件事非常顺手。我们需要保存的数据无非三项:当前场景名、背包列表、剧情标记集合。
def save_game(self, filename="save.json"): """保存当前游戏状态到 JSON 文件""" data = { "current": self.current, "inventory": self.inventory, "flags": list(self.flags) } with open(filename, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) print("存档完成。") def load_game(self, filename="save.json"): """从 JSON 文件读取游戏状态""" if not os.path.exists(filename): print("找不到存档文件。") return with open(filename, "r", encoding="utf-8") as f: data = json.load(f) self.current = data["current"] self.inventory = data["inventory"] self.flags = set(data["flags"]) print("读档完成。")这里有一个细节很多人会忽略:flags是set类型,但 JSON 格式不支持直接序列化set,所以保存时必须先转成list,读取时再转回set。如果你不加这两步转换,运行时会直接报TypeError: Object of type set is not JSON serializable。
接入主循环时,只需要在输入处理里加两个分支。我习惯支持save和load两个指令,同时也支持s和l这种短缩写,毕竟玩家玩到一半突然打字很麻烦。存档文件名固定用save.json,放在当前目录即可。如果想做多存档位,可以把文件名扩展成档案序号 + 时间戳,但对纯练手项目来说,一个存档位完全够用。
4.2 道具与标记:让剧情产生真正的分支
其实前面展示的故事数据里,已经用到了add_item和set_flag。这里我想再多聊几句这两个机制如何配合,才能制造“真正有意义的分支”。
单纯的if-else跳转只能产生线性剧情,但有了道具和标记,剧情就有了“状态”:玩家是否看过纸条、是否拿到钥匙、是否知道密码,都会影响后续选项的可选状态。比如在wake_up场景里的“回想纸条内容”选项,只有玩家先看过纸条(note_read标记存在)才会变成可选状态,否则会显示“条件未满足”。这个设计不仅让剧情更有层次,还能引导玩家去尝试不同路径,而不是一进入场景就乱选。
实际写剧情时,我建议画一张简单的场景流向图(用纸笔,或思维导图工具都行),把哪些场景需要什么条件、会产生什么效果标清楚。这样写故事数据时才不会出现“玩家根本没去过某个场景,却触发了某个剧情”的 bug。我早年写过一个大项目,就是因为没做这个准备工作,结果测剧情时发现自己可以通过某个隐蔽的组合直接跳到最终结局,一点逻辑连贯性都没有。
4.3 提升沉浸感:打字机效果、清屏与等待
文字冒险游戏的体验上限,很大程度上取决于文本呈现的节奏。如果整段文字一次性打印出来,玩家会觉得像在读代码日志,完全没有氛围。我的做法是给引擎加三个小功能:打字机效果、清屏、以及文本之间的短暂停顿。
打字机效果就是让文字逐字打印,营造一种“角色正在向你叙述”的感觉。实现非常简单:
import sys def slow_print(text, delay=0.03): """模拟打字机输出效果""" for ch in text: print(ch, end="", flush=True) time.sleep(delay) print()关键点是print(ch, end="", flush=True)。flush=True强制刷新输出缓冲区,不然文字会憋在一起直到循环结束才一次性冒出来。这个坑我踩过,第一次写的时候没加flush,结果打出来的效果和普通打印没区别,以为代码写错了,排查半天才发现是缓冲区的问题。
清屏功能也很简单。Windows 下用os.system("cls"),Linux 和 macOS 下用os.system("clear")。因为文字冒险游戏通常要在不同系统上运行,所以我习惯封装一个方法,根据os.name判断用哪个命令。
def clear_screen(self): """清空终端屏幕""" os.system("cls" if os.name == "nt" else "clear")不过这里要提醒一句:如果只是本地开发调试,清屏反而会干扰你看日志,所以我一般在正式运行或者给朋友演示时才开启。如果想做得精细一点,可以在游戏开场加一个“是否开启演出效果”的选项,让玩家自己选。
5. 常见问题排查与 PyInstaller 打包成 exe
5.1 中文乱码:Windows 控制台编码问题
这可能是 Windows 版 Python 新手遇到最多的坑。为什么代码在 VSCode 里跑得好好的,一拿到系统终端(cmd)里就是一堆乱码?根因在于 Windows 传统控制台的默认编码是 GBK,而 Python 3 默认字符串编码是 UTF-8,两者不一致,中文一输出就炸了。
我的解决方案是在程序入口处加一段编码兼容代码:
import sys if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8")sys.stdout.reconfigure(encoding="utf-8")这个 API 从 Python 3.7 开始支持,作用是重新配置标准输出的编码方式。加上之后,绝大多数中文乱码问题都能解决。如果使用者的系统终端仍然显示异常,可以再手动执行chcp 65001把终端代码页切到 UTF-8。
另外,读文件的时候也要养成习惯,在open()中显式指定encoding="utf-8",而不是依赖系统默认编码。这一步在存档读档部分尤其关键,因为 Windows 下如果用了默认编码去读一个 UTF-8 的存档文件,轻则乱码,重则直接抛UnicodeDecodeError。
5.2 输入与循环的边界情况
文字冒险游戏的主循环本质上是while True,所以最怕玩家输入什么都不会导致程序崩溃或死循环。我见过最典型的问题是:用户输入了整数范围外的数字,程序没有做边界判断,结果直接抛IndexError。这个在 3.2 的代码里已经处理了:先isdigit()判断是不是数字,再if 0 <= idx < len(choices)判断范围,双保险。
还有一个容易被忽略的点:玩家输入了0或者负数。int("0") - 1 = -1,如果只判断idx < len(choices),-1会被当成列表最后一个元素,导致玩家输入0却意外触发最后一个选项。所以要加上0 <= idx的判断。
另外,input()读取到空字符串时,strip()之后会变成空串,isdigit()返回False,走else分支提示“无法识别的指令”,不会崩溃。但如果代码里没有调用strip(),用户多打了个空格,指令就会全部失效,这是很多新手会忽略的细节。总之,凡是终端交互的程序,都要以“玩家一定会乱输”为前提去写防御逻辑。
5.3 打包成 exe:PyInstaller 的正确姿势
游戏写完了,想发给不装 Python 的朋友玩,最直接的办法就是打包成 exe。这里我推荐 PyInstaller,它是目前最成熟的 Python 打包工具之一。
pip install pyinstaller pyinstaller -F game.py-F参数表示打包成单个 exe 文件。打包完成后,dist 目录里会出现game.exe,直接双击就能运行。这里有几个关键注意事项,都是实操中能帮你省时间的:
第一,文字冒险游戏是命令行程序,打包时不要加-w参数。-w的作用是隐藏控制台窗口,主要给 GUI 程序用。如果你打包文字冒险游戏时加了-w,运行时会发现窗口一闪而过,什么交互界面都看不到,因为输出日志和控制台输入全被藏了。
第二,如果游戏代码里除了game.py还有其他资源文件(比如 JSON 存档模板、音频文件),需要在打包命令中手动添加资源路径,否则 exe 运行时找不到文件。一个常见做法是用--add-data "data.json;."这样的参数把资源文件打进包里,然后在代码里用sys._MEIPASS获取解压后的资源路径。这里篇幅有限,我先不展开,一句话总结:先不加资源文件,把最基础的可执行文件跑通,再逐步加资源。
第三,PyInstaller 打包出来的 exe 体积通常不小,一个纯标准库的脚本大概也会到 5MB 以上,这是正常现象,不用焦虑。因为有 PyInstaller 是把 Python 解释器和依赖库一起打包进去,体积大是必然的,已经比很多 GUI 应用小多了。
6. 做完这个项目的真实体会
6.1 我实际做完后的几个感受
这个项目最让我惊讶的地方,是它的“投入产出比”比你想象中高得多。结构上只有几个字典、一个类、一个主循环,但跑通一整个流程之后,你会觉得自己真的做了一个“产品”:有标题、有剧情、有存档、有结局。这种完整的正反馈,是刷语法题很难给到的。
另外,调试过程本身就是一次历练。我在测试第一个完整流程时,曾经出现过“拿到了钥匙却打不开门”的 bug,排查到最后才发现是need_item里的道具名称和add_item里的名称少写了一个字,导致匹配永远失败。这种低级错误提醒我:数据结构的命名必须统一,最好定义一个常量或者在代码里直接引用同一个字符串变量,避免手打出错。游戏越小,对这种细节的容忍度越低。
6.2 可以从这里继续深挖的四个方向
如果你做完这个基础版还不过瘾,我建议按下面的顺序尝试扩展:
第一个方向是把故事数据从“代码里的字典”挪到“外部的 JSON 文件”。这样游戏代码完全不动,任何人都可以通过修改 JSON 文件来写自己的剧情,相当于实现了“编辑器与引擎分离”的雏形。这也是我目前认为收益最大的一次重构,做完之后你再回头看,代码层和内容层已经被彻底剥离开了。
第二个方向是增加“随机事件”系统。比如探索过程中有一定概率触发随机场景或者随机掉落的道具,这样每次游戏体验都会不太一样。实现思路很简单,用random.random()或random.choice()在主循环里决定是否跳转到特殊场景即可。
第三个方向是给自由输入扩展更丰富的语义。除了关键词匹配,还可以尝试用difflib库做简单的模糊匹配,让玩家即使输入“打开那个门”这样的不精确指令,也能识别出意图。
第四个方向是联网排行榜或成就系统。虽然文字冒险游戏本身很单机,但你可以把通关时间和结局记录写到本地文件,甚至学习一下 HTTP 请求把成绩提交到自己的小服务器上。这一步会引入网络编程和数据库,完全是另一个大坑,但足够你玩很久。
就我个人而言,我很建议把第一个 JSON 外置方向优先做了。因为你会发现,当故事数据和引擎代码分离之后,你写下一款游戏时根本不用重新写引擎,只需要换一个故事 JSON 文件。平台已经搭好,剩下的只是创作本身。这也是我从这个小项目里得到的最大收获。