ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Python基础语法写一个文字冒险游戏:状态机与字典实战

用Python基础语法写一个文字冒险游戏:状态机与字典实战 如果你把Python基础语法学完了正愁没东西练手我特别推荐你写一个文字冒险游戏。这个项目不大却能把你学过的变量、字典、函数、循环全部串起来而且做出来的东西能跑、能玩朋友也能试玩成就感比做一百道练习题都强。文字冒险游戏听起来像是上世纪的老古董可它的核心机制其实特别适合练编程你给玩家一段描述玩家做选择程序根据选择跳到下一个场景中间穿插状态变化、随机事件、胜负判定。整个流程里没有复杂的图形渲染也没有物理碰撞你只需要专注一件事把游戏逻辑表达清楚。这正是Python初学者最需要的能力。我在带新人做项目时发现很多人卡在“学完了不知道怎么用”这个阶段。刷题只能练局部语法做小工具又不够有趣一上来碰爬虫或数据分析又会牵扯一堆第三方库。文字冒险游戏恰好卡在一个刚刚好的位置只用标准库就能写完代码量控制在两三百行以内却能把输入输出、流程控制、数据组织、函数拆分都覆盖到。你要是认真做完顺手还能学会怎么设计一个小程序的结构比照着教程抄十遍都管用。1. 项目整体设计与核心思路1.1 文字冒险游戏到底在练什么别小看这个“老掉牙”的游戏类型。它的本质是一个状态机游戏世界里有很多个“节点”每个节点代表一个场景场景里有若干选项选项连接着下一个场景玩家通过输入选择在节点间移动直到走到终点。这个模型放到任何软件开发里都能派上用场比如菜单导航、表单分步填写、Bot对话流程原理都是同一套。所以我不建议你一开始就想着做多复杂的画面而是先把“节点选项跳转”这个核心跑通。你会在过程中反复练习这几件事用字典组织数据而不是堆一堆乱七八糟的变量用循环处理重复的输入提示和判断而不是复制粘贴代码用函数隔离逻辑让每个部分都能单独测试用条件判断根据玩家状态切换剧情分支。这些恰恰是Python基础阶段最容易被忽视的部分。很多人学完了if和while却不知道什么时候该用、怎么组合。做一个完整的文字冒险游戏这些都会自然地被逼着用起来。1.2 游戏玩法设计从需求到功能我建议第一次做尽量把玩法控制在“能讲完一个故事”的范围里。不用一上来就搞开放世界那会让代码量爆炸。你可以先定一个小目标玩家扮演一个角色在几个场景之间探索最后至少有一个胜利结局和一个失败结局。以我常用的“森林冒险”为例玩法大概是这样的玩家在小木屋醒来可以选择出门、翻柜子、继续睡出门后进入森林可以选择去河边、走小径、爬树河边可能有武器或陷阱小径可能遇到怪物树上能看到全局地图如果生命值归零游戏结束如果找到“古老护符”再回到小木屋就能触发胜利结局。这个设计里除了“场景跳转”还加入了生命值和物品两个状态。你在写的时候会发现光有场景跳转太单薄玩家会觉得只是在看电子书加上生命值和物品后选择就有了代价和意义游戏性立刻就出来了。但注意第一版不需要太多物品两三样就够重点是让状态在场景之间传递。1.3 技术选型为什么坚持只用标准库有不少人一上来就搜“Python游戏开发”然后装Pygame。我承认Pygame很强大但用在文字冒险上有点杀鸡用牛刀还会把初学者带偏你还没把游戏逻辑理清楚就要应付窗口、事件循环、贴图、碰撞检测很快就会被劝退。我更推荐只用Python自带的库也就是sys、random、json这些。原因有三点第一环境问题最少。初学者最容易卡在“装不上库”这一步你只用标准库只要能运行print(hello)就能跑这个游戏完全绕开第三方包管理的坑。第二逻辑更透明。整个游戏从头到尾只有你自己写的代码出了问题能一行行查看不用去猜库内部发生了什么。这对调试能力的训练特别重要。第三后面想扩展很容易。等你把核心逻辑写顺了再引入Pygame做图形界面或者用tkinter做窗口都不是难事。因为游戏逻辑已经和界面解耦了你换掉的是外壳不是内核。2. 核心数据结构与状态流转2.1 用字典模拟“房间”和“门”文字冒险游戏最核心的数据结构我推荐用字典而且是用“场景ID”作为key用另一个字典作为value。你可以把每个场景想象成酒店里的房间房间里有描述文本、可以做的几件事、每件事通向哪个房间。这样的结构天然适合字典。一个最简单的房间长这样rooms { cabin: { name: 林间小木屋, desc: 你在一间落满灰尘的小木屋里醒来墙角有一扇门通向外面。, options: [ {text: 推开门出去, next: forest}, {text: 搜一下柜子, next: cabinet}, {text: 继续睡觉, next: gameover_sleep} ] }, # ...其他房间 }我特意给每个场景起了英文的房间ID比如cabin、forest。原因是字典的key是程序内部用的如果用中文也没问题但一旦场景多了英文ID不容易打错而且一眼能看出逻辑关系。你可能会问为什么不让options直接是字符串而是放一个列表里面套字典因为后面很可能要给选项加条件比如“只有你拿到了钥匙才能打开这扇门”。如果options是纯字符串列表这个功能写起来就很别扭用字典列表每个选项都能携带额外信息比如require: key, consume: True。这种“房间—门—房间”的模型我用了很多年不只写文字冒险写很多交互式脚本也会参考它。它的好处是数据和控制逻辑分离场景内容是数据怎么显示、怎么跳转是逻辑。想加新场景只需要往字典里加一个房间不需要改主循环。2.2 玩家状态的表达生命值、背包、标记位除了场景数据你还需要记录“玩家此刻的状态”。最直觉的做法是定义几个全局变量hp 100、bag []、has_key False。小项目这么干没问题但你想一下如果玩家走到一个场景需要根据包里有没有钥匙来决定出不同的文本那你每走一步都要检查这些变量代码会越来越乱。我的习惯是把玩家状态也装进一个字典里player { hp: 100, bag: [], flags: {} }这样做的理由很朴素所有状态集中在一个地方存档的时候方便读档也方便而且场景数据里可以写类似require: key的字段引擎去player[bag]里查有没有key代码结构会清晰很多。flags字段是用来记“剧情标记”的很多游戏引擎里也这么叫。比如你昨天救了一只猫今天遇到猫时就要触发报恩剧情。你不用保存一个“是否救过猫”的变量而是设置flags[saved_cat] True。等遇到猫时直接查这个标记即可。用字典管理布尔状态比堆一堆is_saved_cat变量清爽太多。2.3 游戏主循环不让程序在一张场景图里打转游戏运行的核心是一个无限循环这个循环的套路非常固定获取当前房间数据展示房间标题、描述、选项读入玩家输入校验输入执行跳转或者应用状态变化判断是否满足结束条件不满足就回到第1步。写成伪代码就是current cabin while current: room rooms[current] show_room(room) choice get_choice(room) current do_effect(choice, player)为什么用while current:而不是while True:因为我把“游戏结束”也设计成一种特殊状态当current变成空字符串时循环自然退出。这样你不需要在循环里写一堆break只需要让某个选项的next指向空字符串游戏就干净利落地结束了。这个设计我在第一次写的时候没想到后来是被“怎么退出循环”这个问题烦了好久才想明白的。这里有个特别重要的点永远不要用递归来实现场景跳转。比如让show_room()函数在玩家点击选项后递归调用自己看起来代码很短但一旦剧情长了会直接把Python的递归上限跑炸还很难定位错误。场景跳转是“状态更新”不是“递归调用”用循环才是正解。3. 实操一个可以跑的森林冒险游戏3.1 准备环境与项目结构在动手写代码之前先确认你电脑上的Python环境是能用的。如果你还没装Python去官网下载安装包安装的时候记得勾选“Add Python to PATH”。这一步经常被忽略导致你在命令窗口输入python却提示找不到命令。装好之后打开终端输入python --version能输出版本号就说明环境没问题。我建议新建一个项目文件夹里面按功能拆成两个文件text-adventure/ ├── game.py # 主程序包含引擎逻辑 └── data.py # 场景数据只放剧情和配置有人会问这么小的项目拆两个文件是不是多此一举我的回答是数据量和逻辑分开你自己维护起来会轻松很多。你想想几百行剧情字典和几十行逻辑代码混在一起想改一个错别字都得滚半天鼠标。拆开后写剧情时不用关心逻辑调逻辑时不用被剧情干扰。这是“关注点分离”的第一次实践比什么高深的设计模式都实在。如果你习惯用VSCode或PyCharm直接在项目文件夹里打开编辑器就行。有耐心的同学还可以用python -m venv venv创建一个虚拟环境虽然这个项目用不到第三方库但提前养成隔离环境的习惯以后做爬虫或数据分析项目时会感谢现在的自己。3.2 场景字典的完整定义下面是我用来演示的完整场景数据你可以直接抄也可以改成自己的故事。写的时候注意每个场景的options都至少给两个选项让玩家感觉自己有选择权但不要给太多两个到四个正合适。选项过多会让玩家疲劳也让你的数据量翻倍。# data.py rooms { cabin: { name: 林间小木屋, desc: 你在一间落满灰尘的小木屋里醒来。窗外是茂密的森林屋里只有一张床、一个旧柜子和一扇半掩的门。, options: [ {text: 推开门走出去, next: forest}, {text: 打开旧柜子看看, next: cabinet}, {text: 躺回床上继续睡, next: gameover_sleep} ] }, cabinet: { name: 旧柜子, desc: 柜子里没有衣服只有一把生锈的钥匙和一张泛黄的纸条。纸条上写着‘河边石头下藏着护符。’, options: [ {text: 拿走钥匙, next: cabin, effect: take_key}, {text: 不管它回到屋里, next: cabin} ] }, forest: { name: 森林空地, desc: 你走出小木屋迎面是参天大树。阳光透过树叶洒下来前方有两条路一条通向小河边另一条通向幽暗的小径。, options: [ {text: 往河边走, next: river}, {text: 走幽暗的小径, next: path}, {text: 爬上一棵大树看看远方, next: tree} ] }, river: { name: 小河边, desc: 河水很浅河底铺满鹅卵石。你想起纸条上的话翻开一块大石头下面真有一个古老的护符。, options: [ {text: 捡起护符, next: forest, effect: take_amulet}, {text: 不碰它原路返回, next: forest} ] }, path: { name: 幽暗小径, desc: 小径越走越暗突然窜出一只野狼拦住去路。它盯着你露出锋利的牙齿。, options: [ {text: 与野狼搏斗, next: combat, need: amulet, fail_text: 你没有武器赤手空拳根本不是野狼的对手……}, {text: 掉头跑回空地, next: forest} ] }, tree: { name: 大树顶端, desc: 你爬上树顶看见森林尽头有一片湖泊小木屋就在你脚下。远处似乎有一条小路通向一座废弃的塔楼。, options: [ {text: 滑下树回到空地, next: forest} ] }, combat: { name: 与野狼对峙, desc: 护符发出一道微光野狼似乎有些畏惧。你趁机捡起一根木棍把它赶跑了。, options: [ {text: 继续前进, next: tower} ] }, tower: { name: 废弃塔楼, desc: 塔楼底层有一扇沉重的石门门上刻着一行字‘拥有护符者方能开启命运之门。’, options: [ {text: 用护符打开石门, next: victory, need: amulet, fail_text: 你身上没有护符石门纹丝不动。}, {text: 返回森林空地, next: forest} ] }, victory: { name: 结局命运的守护者, desc: 石门缓缓打开你看见满墙的古老壁画。你意识到自己找到的不只是一枚护符而是一份守护森林的使命。, options: [] }, gameover_sleep: { name: 结局一觉天亮, desc: 你闭上眼睛再次醒来时已经是第二天清晨。冒险还没开始就结束了。, options: [] } }你可以看到我用了need字段表示“可能需要某物品”用fail_text表示“条件不满足时显示的提示”用effect表示“进入这个选项后要执行的额外动作”。这些字段都不是必须的但加上之后游戏的故事张力会强很多。比如走到野狼面前你如果没有护符就不能选“与野狼搏斗”这个选项就算选系统也会提示“你没有武器”。这就是我一开始说的让选项携带条件需要引擎配合检查。如果你只想要一个“选择分支”的demo可以先把need和effect忽略等基础跑通了再补。3.3 引擎函数与交互逻辑接下来是主程序。我习惯把功能拆成四个函数show_room(room)展示房间信息get_choice(room)获取并校验玩家输入apply_effect(effect, player)执行选项附带的状态修改main()主循环串联所有逻辑。# game.py import data def show_room(room): print(\n * 30) print(【 room[name] 】) print(room[desc]) if not room[options]: return print(- * 30) for i, opt in enumerate(room[options], 1): print(f{i}. {opt[text]}) def get_choice(room): options room[options] while True: raw input( ).strip() if not raw: print(输入不能为空请输入数字序号。) continue if raw.isdigit(): idx int(raw) - 1 if 0 idx len(options): return options[idx] print(f无效输入请输入 1 到 {len(options)} 之间的数字。) def apply_effect(effect, player): if effect take_key: player[bag].append(key) print([提示] 你拿到了生锈的钥匙。) elif effect take_amulet: player[bag].append(amulet) print([提示] 你获得了古老护符) elif effect take_damage: player[hp] - 10 print(f[提示] 你受到了伤害当前生命值{player[hp]}) def check_option_available(opt, player): need opt.get(need) if need and need not in player[bag]: return False return True def main(): player {hp: 100, bag: [], flags: {}} current cabin while current: room data.rooms[current] show_room(room) if not room[options]: break available_options room[options] # 过滤掉条件不满足的选项 valid_options [] for opt in available_options: if check_option_available(opt, player): valid_options.append(opt) else: print(f不可选{opt.get(fail_text, 条件不满足)}) if not valid_options: print(没有可用的选项你被困在了这里……) break choice get_choice({options: valid_options}) effect choice.get(effect) if effect: apply_effect(effect, player) current choice[next] print(\n游戏结束。感谢游玩) if __name__ __main__: main()get_choice这个函数值得单独说。玩家输入是程序最容易出错的地方你不能假设对方一定会老老实实输入“1”。可能多敲一个空格可能输入了汉字可能直接按回车。我用了一个while True循环来做校验只要不合法就重新问一遍。input().strip()是必须的它能去掉首尾空格isdigit()则确保用户输入的是数字。还有一个细节我这里在main里过滤了“条件不满足”的选项并把不可用的选项用不存在的语义提示出来但又没有把它删掉这样玩家能看到“原来这里有个选项但我暂时去不了”故事感更强。如果你想让剧情更紧张也可以不显示不可用选项让玩家以为这条路根本不存在。这两种做法都行看你想要什么样的游戏体验。3.4 启动入口与流程演示把data.py和game.py放在同一个目录下然后运行python game.py程序会进入第一个场景看起来像这样 【林间小木屋】 你在一间落满灰尘的小木屋里醒来。窗外是茂密的森林屋里只有一张床、一个旧柜子和一扇半掩的门。 ------------------------------ 1. 推开门走出去 2. 打开旧柜子看看 3. 躺回床上继续睡 这时候输入2进入柜子场景捡到钥匙后回到小木屋。你会发现木屋的选项还是那三个但如果你选择“打开旧柜子看看”会再次进入柜子柜子里的选项包括“拿走钥匙”即使你已经拿过也可以再拿一次。这在小项目里是正常的不需要额外处理但如果想做得更精细可以给take_key加一个条件当player[bag]里已经有key时柜子里的描述变成“柜子已经空了”。这个判断可以在场景数据里加if_flag字段或者在apply_effect里处理。我建议第一版先忽略这种小瑕疵跑通主线再优化。顺着流程继续从木屋出门去河边捡护符然后走小径就不会被野狼挡住最后去塔楼开门通关。如果你不捡护符直接走小径系统会提示不可选你只能原路返回。这就是整个游戏最核心的体验你做的每个选择都会影响后面的路能不能走通。4. 常见问题与调试实录4.1 Windows下中文乱码我见过太多新手第一次运行程序中文全部变成乱码。大部分原因是Windows终端默认编码不是UTF-8。如果你用的是CMD可以在Python文件最顶上加上# -*- coding: utf-8 -*-这个声明在Python 3里其实不是必需的因为Python 3默认源码就是UTF-8。真正的坑在于“输出到终端”时的编码。解决办法有几个用VSCode打开终端运行VSCode默认UTF-8在CMD里先执行chcp 65001切换代码页在代码里重定向标准输出编码import sys import io sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)但最后这个方法在有些环境下反而会出问题所以我的建议是直接用VSCode或者PyCharm的终端来运行省心。另外input()读入中文在Windows下也可能有编码问题所以我在游戏里只让玩家输入数字尽量避免直接输入中文比较。这个设计既解决了编码问题也降低了输入校准的难度。4.2 input()读入的脏数据很多人会在get_choice里写类似choice input( ) if choice 1: ...这种写法对用户来说太不友好。玩家如果输入1或者1.程序就会判断失败。所以不管做什么项目用户输入都建议做三步处理去空格、判空、类型转换。我习惯把输入处理封装成一个小函数而不是在业务逻辑里到处写input()。这样做的好处是以后想换成GUI输入框只需要改一个函数别的代码不动。另外如果你使用input()记得程序里不要同时使用sys.stdin.readline()两者混用会互相干扰导致有时候回车被吞掉。这是我早期调试时踩过的一个很莫名其妙的坑。4.3 小心递归陷阱与无限循环前面提过场景跳转必须用循环而不是递归。我再补充一个边界情况如果某个场景的选项next指回了自己比如“继续睡觉”回到“睡觉中”程序可能会陷入无尽的输出循环。这通常不是bug而是剧情设计的问题。比如我想让“继续睡”重复三次后再进入另一个结局就不能让它无条件next到自己而是要配合flags计数每次进入这个场景给flags[sleep_count] 1当计数等于3时next变成另一个房间。很多人刚开始写这种计数逻辑会自然想到用for循环但别忘了这里是在一个主循环里并不能简单地用for控制次数。你需要的是“状态累积”也就是每次进入该场景时修改某个变量然后根据变量决定去向。这也再次说明把玩家状态放进字典里是多么方便。还有一个小陷阱在while current:这个循环里如果某个场景的options列表为空get_choice会一直卡住。所以我在main里加了if not room[options]: break这个判断确保游戏能在结局场景正常结束。4.4 代码越写越烂尽早用函数拆解新手往往喜欢把所有逻辑塞在一个while循环里写着写着就变成一团乱麻。我见过有人把整个游戏写成几百行的while True加if一旦要加新功能改一处就崩三处。与其这样不如在动手前先想清楚哪些事是反复做的把反复做的事情提出来当函数。文字冒险游戏里反复做的事情很明显显示房间、读取选择、应用效果、判断条件。四个函数足够了。如果某个函数超过三四十行就考虑再拆。比如apply_effect以后要处理各种道具分支会越来越多到时候你可以拆成use_key()、use_amulet()这样的子函数再用字典映射效果名和函数effects { take_key: use_key, take_amulet: use_amulet, }这样再增加新效果你不用把if elif写成一长串只要新增一个函数往字典里加一项就行。这个技巧在很多项目里都能用说白了就是“查字典代替写if”。5. 进阶加存档、加物品、打包给别人玩5.1 存档读档用JSON把世界“冻结”起来文字冒险游戏玩到一半如果关掉终端再打开一切就会从头开始玩家肯定不爽。所以我强烈建议你给游戏加上存档功能。Python标准库里的json模块正好干这个事。核心思路很简单把当前场景ID和玩家状态保存成一个字典然后用json.dump写进文件。读档时用json.load读出来恢复变量。import json def save_game(current, player, filenamesave.json): data { current: current, hp: player[hp], bag: player[bag], flags: player[flags] } with open(filename, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(游戏已保存。) def load_game(filenamesave.json): with open(filename, r, encodingutf-8) as f: data json.load(f) player { hp: data[hp], bag: data[bag], flags: data[flags] } return data[current], player你可能注意到我用了ensure_asciiFalse这样中文会以可读方式存进JSON文件不然全是\u转义也没法手动改了。indent2纯粹是为了方便你编辑存档文件实际上Python读的时候完全不在乎缩进。在游戏循环里可以在选项列表里加一个隐藏指令比如玩家输入0表示存档输入-1表示读档。但要注意你的get_choice目前只接受数字序号如果以后要加这些特殊指令就得改造输入解析函数。我建议把命令分成两类普通选项和特殊指令特殊指令用前缀区分比如/save、/load这样不会和场景选项冲突。5.2 战斗与物品给选择题加一点变量如果觉得纯跳转不过瘾可以加一个最简单的回合制战斗。假设玩家遇到野狼时不再直接判定胜负而是进入一个战斗子循环def battle(player, enemy_hp30): print(野狼出现了) while enemy_hp 0 and player[hp] 0: action input(攻击(a) / 逃跑(r) ).strip().lower() if action a: damage random.randint(5, 15) enemy_hp - damage print(f你造成了 {damage} 点伤害野狼剩余 {enemy_hp} HP) if enemy_hp 0: dmg random.randint(3, 10) player[hp] - dmg print(f野狼反击你受到 {dmg} 点伤害剩余 {player[hp]} HP) elif action r: print(你转身逃回了空地。) return forest else: print(请输入 a 或 r) if player[hp] 0: print(你击败了野狼) return path_win else: return gameover_lose这个循环虽然简单但它引入了“回合”的概念玩家行动一次敌人行动一次双方生命值都在变。你会发现游戏真正变得好玩是因为选择有了即时反馈。把这段代码接到原来“与野狼搏斗”的选项上整个游戏的紧张感立刻上来了。当然战斗系统会引入随机性。random.randint(5, 15)表示每次攻击造成5到15点浮动伤害。如果玩家脸黑可能打很久都打不死野狼。这时候建议给玩家一个保底策略比如逃跑选项不然体验会很差。5.3 打包成exe发给没装Python的朋友做完游戏你肯定想发给朋友玩玩。但对方电脑上未必装了Python这就轮到“转exe”出场了。最常用的工具是PyInstaller它会把你的脚本和Python解释器一起打包生成一个独立的可执行文件。安装它pip install pyinstaller然后在项目目录执行pyinstaller -F -n 森林冒险 game.py参数说明-F打包成单个exe文件方便分发-n指定生成的文件名不加-w因为我们需要控制台界面来显示文字。打包完成后在dist目录下会有一个“森林冒险.exe”双击就能运行。如果你的游戏里有配套的data.pyPyInstaller会自动把同目录下的模块打进去不需要你额外操作。但要注意如果之后你加了存档功能exe运行时的“当前目录”不一定是exe所在目录保存存档时最好用绝对路径或者用Path(__file__).parent来定位。不然玩家可能抱怨“存档存到哪去了”。5.4 下一步可以怎么继续练做完这个项目你已经把Python的最核心部分练了一遍。想继续提升我建议按顺序做三件事第一重写一遍代码。不要看原代码从头开始写自己的故事尝试加一个你喜欢的独特机制比如“跟随NPC”或者“解谜密码”。重写的价值在于你会发现第一次写时没想清楚的地方这一次能顺畅地写出来。第二给代码补充类型注解和文档字符串。比如def show_room(room: dict) - None:。这不只是好看它能让你更明确每个数据的类型也方便别人看懂你的代码。第三尝试用tkinter或Pygame给它加个简单图形界面。你会开始理解“界面”和“逻辑”为什么要分离。如果只是把文字游戏套上一个窗口改动量不是很大但如果要继续套3D引擎那你就有得折腾了。不过那就是另一个故事了。这个文字冒险游戏我已经带过很多人写过每次都能看到同一个规律一开始大家觉得字典和函数很抽象等真正做完一个小故事突然就通了。编程这东西靠看永远学不会把一个能玩的东西亲手造出来比什么语法书都管用。希望你这个森林冒险能玩得开心。
返回列表