1. 从零开始搭一个可扩展的冒险引擎结构,别上来就堆if else
做文字冒险游戏这事,最初起源于我想练 Python 的一个朴素念头——写点能直接拿给别人玩的小项目。市面上的练手项目要么是爬虫,要么是数据分析,对刚入门的人来说门槛其实不低,而单纯打印九九乘法表又太无聊。文字冒险游戏这个品类恰好卡在一个很舒服的位置:它不需要图形界面、不需要游戏引擎,一个终端窗口就能跑,但它又必须具备完整的程序结构、交互逻辑和状态管理,练到的全是实打实的基础功。
不过如果你们去搜教程,会发现大量文章让新手这么写:
python复制print("你站在一个黑暗的房间里。")
action = input("接下来做什么?")
if action == "go north":
print("你走进了北边的走廊。")
action2 = input("接下来做什么?")
if action2 == "take key":
...
这种写法在交互只有十几次的小 Demo 里勉强能跑通,可一旦想写一个稍微像样的故事——有七八个房间、十来个物品、三四种结局——整个代码就会变成一座无人能维护的屎山。每次加一个新地点都要改一堆入口和出口,每次加一个新物品都要在所有可能用到它的地方插入判断,改到最后自己都忘了哪个分支在哪个位置。
我决定换个思路:先搭一个最简单的“引擎”,把房间、物品、命令解析、游戏状态这些底层逻辑封装成独立模块,故事本身只作为数据喂进来。换句话说,程序的核心不是“写一个具体的故事”,而是“写一个能跑任何故事的播放器”。
这其实也是很多真正的文字冒险框架(比如 Inform 7、TADS 这类专业工具)的设计理念,只不过我们用纯 Python 用一个下午就能搭出够用的版本。
具体来说,我的引擎由四部分组成:
- 房间系统:负责地图布局、出口连通、房间内的物品摆放。
- 物品系统:负责物品的属性(能不能拿走、能不能用在某些地方)、物品之间的互动。
- 命令解析器:把玩家输入的自然语言拆成“动词+宾语”结构,再决定怎么执行。
- 状态管理:记录玩家走过哪些房间、背包里有什么、哪些事件已经触发过。
四块各自独立,彼此通过明确的接口通信。这样做的好处是:以后想加一个新的交互动作,不需要去翻整个代码,只需要在动作注册表里加一项,然后在对应的房间和物品上挂好响应逻辑就行。故事内容更是完全解耦,换一个剧本就等于换一份 JSON 或 Python 数据文件。
这篇文章我会用一个完整的可运行示例来演示这套思路怎么写。示例的故事背景设定为“深夜潜入一栋旧办公楼,寻找一份关键证据”,包含多个房间、物品组合、谜题和两种结局。读者朋友可以直接抄这份代码骨架,换成自己的故事设定。
需要说明的是,我默认你已经装好了 Python 3.8 或更高版本。如果还没装,去官网下载安装包时记得在安装向导里勾选 Add Python to PATH,这个问题几乎占了新手安装报错的八成原因,先踩平这个坎再继续。
接下来我先从引擎底层的类和数据结构讲起,这是整个项目的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 引擎底座拆解:Room、Item、Action,以及我为什么用类图而不是纯脚本
我见过很多人写文字冒险时用字典存房间,比如 rooms = {"north_room": {"name": "北走廊", "desc": "..."}}。这个做法比一堆 print 加 if 好一些,但字典存数据的问题在于缺乏行为约束——你能存键值对,却不能自然地让一个房间“知道自己被首次进入后该改变描述”,也不能让一个物品“知道自己被拿起后触发了某个全局事件”。这些逻辑如果全塞在命令解析里,最终还是会变成大杂烩。
所以我在这个项目里用了三个类来建立模型的骨架:Room、Item、Action。
2.1 Room 类:房间不只是名字和描述的容器
房间需要维护的东西比直觉上多。除了名字 name、描述 description,还包括:出口表 exits(方向到目标房间 ID 的映射)、摆在这个房间里的物品 items、以及一些和故事进度挂钩的附加属性。
python复制# engine/room.py
class Room:
def __init__(self, room_id, name, description, exits=None, items=None):
self.room_id = room_id
self.name = name
self.description = description
self.exits = exits if exits else {}
self.items = items if items else {}
def describe(self):
"""返回房间可见的描述文本。子类可覆盖此方法实现变化描述。"""
text = f"{self.name}\n{self.description}\n"
if self.exits:
text += "可以走的出口:" + "、".join(self.exits.keys()) + "\n"
if self.items:
visible_items = [v.name for k, v in self.items.items() if v.visible]
if visible_items:
text += "你在这里看到了:" + "、".join(visible_items) + "\n"
return text
def add_item(self, item):
self.items[item.item_id] = item
那这些属性为什么要字段化而不是写死?以“可以走的出口”为例,很多文字冒险游戏里出口是动态变化的——某个门一开始锁着,拿到钥匙才能打开;某个密道在点亮蜡烛后才出现。如果出口只是一个字符串列表,那么“开门”这个动作就是一个修改列表值的过程,依然不好管理。
我更推荐出口用字典存,值不直接写死成房间 ID,而是写一个判断函数。函数返回目标房间 ID,返回 None 就代表此路不通。这样“锁住的门”就变成了一个天然携带逻辑的对象,而不是散落在各处的 if 判断。
示例:
python复制def basement_door_exit(game_state):
if game_state.flags.get("basement_door_unlocked"):
return "basement"
return None
这种设计初期看起来有点过度设计,但你多写几个故事以后会发现,文字冒险里一个房间的输出内容经常因为剧情进度而变化,把这种变化收敛到函数里值得。
2.2 Item 类:可拿取、可观察、可交互
物品类承担的责任更多。一个物品至少要能回答三个问题:能不能装进背包?玩家看它时显示什么?它能和其他物品或房间发生什么互动?
python复制# engine/item.py
class Item:
def __init__(self, item_id, name, description, takeable=False, visible=True):
self.item_id = item_id
self.name = name
self.description = description
self.takeable = takeable
self.visible = visible
def use(self, target_item_id, game_state):
"""默认的物品互动:什么都不发生。
子类可覆盖此方法实现特殊逻辑。"""
return False, f"似乎没什么反应。"
def describe(self):
return self.description
这里我刻意让 use 方法返回一个元组 (是否成功, 反馈文本),而不是只返回一句提示。因为这个返回值会被命令解析器进一步利用——如果某个物品互动让谜题推进了一
