从输入框里敲下第一行print(你好欢迎来到冒险世界)的那一刻你其实已经摸到了很多所谓老程序员第一次写游戏时的门道。我见过太多人学Python今天看变量明天看字典语法都会但一合起来就不知道能干什么。文字冒险游戏就是那个能把列表、字典、函数、流程控制全部串起来的最小但完整的项目——不做图形界面不算复杂算法却可以让你亲手做出一个能玩的东西而且做出来的成品真的能拿给朋友试玩那种正反馈比刷一百道练习题都强。这篇内容就围绕用Python做文字冒险游戏这件事展开适合刚学完基础语法但还没写过完整项目的初学者也适合带过孩子或者想给学生出小项目的成年人。从为什么值得做、怎么在纸上设计地图到核心引擎怎么落地、物品战斗存档怎么加再到我踩过坑之后的调试心得一步步说清楚代码可以直接抄逻辑可以直接改。1. 为什么文字冒险游戏是Python新手最好的练手项目很多新手学编程有个通病学了一个月语法却写不出一个完整的程序。Pygame做游戏吧被事件循环和surface搞蒙写爬虫吧又要求熟悉网页结构一上来全是挫败感。文字冒险游戏恰好落在一个刚刚好的难度区间——不需要图形不需要网络不需要并发却天然覆盖了编程里最值钱的几样基本功。1.1 输入、处理、输出的完整闭环一个文字冒险游戏无论做得多华丽核心循环从来都是三句话读玩家输入、根据输入改变游戏状态、把新状态描述给玩家。这三句话对应到代码里就是input()、变量修改、print()。但把这三句话组织成一个可持续运行的循环比背一百遍函数是干嘛的更能让你理解程序是如何长期运转的。我在给朋友家小孩讲这个项目时说文字冒险游戏的引擎本质上和三明治店的点餐程序一样顾客玩家说我要一个牛肉的店员解析器听关键词厨子逻辑层根据关键词做三明治最后端出来输出描述。这个类比比任何教科书里的术语都接地气因为每个人都在生活里用过这种交互流程。1.2 一次覆盖多项式、字典、函数和文件IO你看似只是在做一个游戏实际上已经触碰了Python最核心的数据结构与语法特性场景和出口的关系非常适合用字典嵌套建模物品栏、背包、已解锁状态适合用列表和集合解析玩家输入的指令需要用到字符串的split、strip、lower把逻辑封装成handle_command()、show_scene()这类函数等于自动练习了函数的分解与复用到后面做存档自然过渡到JSON文件和异常处理。这等于你用一个小项目把语法书里最容易学睡着的几个章节全部用了一遍而且是用要解决具体问题的方式用的——带着需求去查记性深得多。1.3 图形游戏和纯脚本之间的最佳台阶直接上Pygame最大的问题不在图形本身而在心智负担太重。你要处理窗口刷新率、响应按键事件、加载图片资源任何一小环出错游戏画面都起不来而新手很难分清楚到底是逻辑错还是渲染错。文字游戏完全避开了这一层。我常跟人说你先在终端里把一个游戏做成好玩的之后再做图形版相当于只换个外壳游戏逻辑照样复用。千万不要反过来一上来就忙着做界面结果逻辑烂成一锅粥。2. 动手写码前先把游戏地图画在纸上所有文字冒险游戏不管剧情多复杂底层都是一张有向图场景是节点出口是边。这一步你可以在代码里做但我强烈建议先在纸上画一遍。直接写字典不是不行但你很容易漏掉某个场景的出口导致玩家走到一个死房间——不是剧情故意设置的死路而是你压根忘记连线了。2.1 一个最小可用的游戏地图设计我建议新手第一次做场景控制在5到8个。不要贪多先把循环做完。下面是我设计的一个深夜森林地图结构非常简单场景名描述梗概出口forest一片黑暗中只有月光透过树冠north → cabinsouth → rivercabin破旧的小木屋桌上有一盏油灯south → foresteast → clearingriver河边水声很响对岸隐约有路north → forestwest → bridgebridge一座摇晃的木桥east → rivernorth → towertower高塔入口门锁着需要钥匙south → bridgewest → cavecave洞穴深处地上有个生锈的钥匙east → towerclearing林中空地月光最亮west → cabin画完这张表你其实已经完成了80%的策划。剧情可以先不写——哪怕全是这里很黑、那里很暗只要结构是通的玩家就能走来走去。这个阶段的目标只有一个保证每个场景至少有一条路回到主路永远不要把出口做成单向断头除非是剧情必要比如结局房间不然玩家卡死了想试试别的方向结果全无反馈很快就会卸载你的游戏。2.2 从纸面到Python字典一个场景容器的写法纸面画完平移成代码就很快了。每个场景用字典表示包含描述文本和出口表我习惯写成下面这种结构world { forest: { description: 你站在一片密林中月光从树冠缝隙洒下来。\n东侧隐约有灯光北边好像有什么建筑的轮廓。, exits: {north: cabin, east: clearing}, }, cabin: { description: 这是一间破旧的小木屋木板墙透着风。\n桌上放着一盏油灯还没有点燃。, exits: {south: forest, east: clearing}, }, clearing: { description: 林中空地月光亮得有些不真实。\n西北方向是木屋东边似乎有一座高塔的影子。, exits: {west: cabin, east: tower}, }, # ...其他场景 }有几点建议描述文本用三引号字符串里面可以写换行排大招时特别好用出口表的键是玩家会输入的方向词值是对应的场景键名这样代码里查起来非常直接场景键名要稳定且具有可读性forest、cabin别用什么sc1、sc2否则写到后面你自己都分不清。2.3 角色初始状态和全局常量除了地图游戏还应该有一点玩家状态。最开始不需要多一个当前场景变量就够。但我惯例会预留玩家名字、生命值、背包三个字段因为后面加东西是必然的先留好缺口比之后重构舒服得多player { name: , hp: 20, inventory: [], current_scene: forest, } TOTAL_HP 20你可能会问hp不是进阶功能吗为什么要一开始就放进player字典里因为你在设计地图时就应该考虑战斗这是游戏设计师的视角——先定数据结构再定玩法规则。而TOTAL_HP这种常量则是为了后面做伤害计算时不会被魔法数字把代码搞乱。3. 核心引擎的落地一个能跑的30行循环很多人以为游戏引擎是个多宏大、多高级的东西。在文字冒险这个场景下引擎就是三样东西的合体读取输入的解析器、维护状态的状态机、输出文本的展示器。我习惯把这三者拆开写哪怕初期每个函数只有几行拆开的好处是后期加功能时你不用在几十行嵌套里找豆腐块。3.1 主循环长什么样主循环是所有游戏的心脏。文字冒险的主循环写出来极其朴素def main(): print(welcome_text()) while True: show_scene(player[current_scene]) cmd input( ).strip().lower() if not cmd: continue if cmd in (quit, exit, q): print(感谢游玩再见。) break result handle(cmd) if result is not None: print(result)我第二十个学生看到这段代码时说过一句话这也太简单了吧 对就这么简单。真正的复杂度全部隐藏在handle()函数里而主循环保持简单的好处是你永远知道程序接下来要干什么——读入一个指令、交给处理器、打印反馈。不管游戏后面做得多大主循环都别再往里面塞别的逻辑否则你很快就会迷失在自己写的面条代码里。3.2 解析器别一开始就上自然语言识别很多人写解析器容易走火入魔想用正则表达式识别各种复杂语法甚至想接入一个中文分词库。别真别。文字冒险的精髓在于动词名词的极简命令。刚开始做只要能处理go north、take key、use lamp、help这样的命令就够用了。我推荐用最原始的split()来做拆词def parse(cmd): parts cmd.split() if not parts: return None, None verb parts[0] obj parts[1] if len(parts) 1 else None return verb, obj然后你可以在handle()里基于 verb 做分支def handle(cmd): verb, obj parse(cmd) if verb go and obj: return move(player[current_scene], obj) if verb take and obj: return take(obj) if verb inventory or cmd i: return show_inventory() if verb help: return help_text() return 我不太明白你的意思试试 go、take、use、help 这些指令。我特别想提醒一个细节解析前一定要strip()和lower()。玩家敲进来的字符串末尾总有多余空格有时还会敲成Go North这种大小写混战的输入。不先做归一化你处理GO就会漏掉玩家分分钟想骂人。这个坑几乎是每个新手的必经之地。3.3 场景移动用字典查表代替一堆if判断场景切换如果写成一长串if scene forest and direction north写到第5个场景你就要爆炸。正确做法是直接查字典def move(from_scene, direction): exits world[from_scene][exits] if direction not in exits: return 那边没有路。 new_scene exits[direction] player[current_scene] new_scene msg f你向{direction}走去来到了一个新地方。\n msg world[new_scene][description] return msg这里有个小地方值得注意world[from_scene][exits]在游戏运行期间不会变所以不存在走不通以外的返回值。而player[current_scene]随时会被更新。如果你把当前场景当成一个全局变量满天飞调试时只靠print很难定位所以我会把所有会发生变化的字段集中在player字典里这样存档时直接序列化player就行状态管理也一目了然。3.4 让输出好看一点统一描述文本的格式内容不好玩代码再优雅也是空的。我发现一个能让游戏立刻提升一个档次的小技巧统一输出格式。比如每个场景描述前面加一行分隔线情节转折时加一两个空行当作停顿。def show_scene(scene_key): scene world[scene_key] print( * 40) print(scene[description]) exit_hints / .join(scene[exits].keys()) print(f[出口]{exit_hints})很多教程不教这个但玩家体验完全不同。统一的格式给玩家一个稳定的预期看到分隔线就知道换场景了看到 [出口] 就知道当前有哪些方向可选。别轻视这些小感性的东西它决定你的游戏是看起来很糙还是有点作品感。4. 进阶玩法物品、战斗与存档等主循环能跑、地图能走游戏就已经活着了。这时候再往里面加内容思路要清晰一次只加一个系统加完立刻试玩千万别同时上物品和战斗否则出了问题你都不知道是哪个模块的锅。4.1 物品栏一个列表搞定大半需求物品系统最简版本就是一个列表player[inventory]。捡东西就是把对应的物品名加进去前提是先判段物品在不在当前场景里。我建议给每个场景额外加一个items字段表示地面上有哪些可拾取物world[cabin][items] [lamp] world[cave][items] [rusty_key]捡取的逻辑可以这样写def take(obj): scene_key player[current_scene] items world.get(scene_key, {}).get(items, []) if obj not in items: return f这里没有 {obj}。 items.remove(obj) player[inventory].append(obj) return f你拾起了 {obj}。用items列表来当地面物品清单逻辑上非常顺手捡走就remove再走到这个场景物品确实不在了这会给玩家一种世界是连续的感觉——虽然他大概率不会专门回去验证但游戏设计师的职业病让所有状态都必须在数据结构里有对应的变化。4.2 使用物品把钥匙开塔门做成第一个解谜解谜的本质是什么条件检测。钥匙能不能开塔门就是当前场景是tower加上背包里有rusty_key两个条件同时满足。def use(obj): if obj rusty_key and player[current_scene] tower: print(你掏出那枚生锈的钥匙插进锁孔咔哒一声塔门开了) world[tower][exits][north] treasure player[inventory].remove(rusty_key) return 你获得了进入【宝藏室】的机会。 if obj lamp: print(你点燃了油灯周围亮了一些。) return 但这似乎并没有带来直接的变化。 return f现在还不能用 {obj}。看到这里你会发现我直接在函数里修改了world[tower][exits]——这意味着世界地图本身是可变的解锁就是明确地往出口表里注入一条新路。这种改变世界状态的设计比用一个全局布尔变量tower_unlocked True更直观玩家以后再使用go north引擎查出口表时天然就有这条路径不需要额外判断。这是文字冒险里一个很常用的模式值得你记在本子上。物品系统有一个容易被忽视的点消耗品要真的从背包里移除。钥匙用掉了就不该还在包里。很多新手只做条件判断通过不做扣除物品结果玩家能用同一把钥匙开所有门逻辑就崩了。同理后面做药水、金币用完都必须leave这一行player[inventory].remove(obj)。4.3 战斗系统最简回合制战斗系统如果做成复杂的有属性克制、技能冷却工作量会骤增。新手第一版我建议做一个最朴素的回合制你和敌人轮流攻击输赢只看生命值。def battle(): enemy_hp 15 print(一个黑影挡在你面前) while player[hp] 0 and enemy_hp 0: cmd input( ).strip().lower() if cmd attack: damage random.randint(3, 7) enemy_hp - damage print(f你挥剑攻击造成 {damage} 点伤害敌人剩余 {enemy_hp} 生命值。) if enemy_hp 0: print(敌人倒下了) break enemy_damage random.randint(2, 5) player[hp] - enemy_damage print(f敌人反击你受到 {enemy_damage} 点伤害剩余 {player[hp]} 生命值。) elif cmd flee: print(你连滚带爬地逃开了。) return fled else: print(战斗中只有 attack 或 flee 可用。) if player[hp] 0: print(你倒下了……游戏结束。)这里有两个新手常踩的坑。第一个是战斗状态与场景状态纠缠不清战斗启动后玩家输入go north会被上面的分支当作无效指令处理但等战斗结束回到主循环时当前场景没变——这其实是合理的只要你没把移动写进战斗分支里主循环自己会恢复。第二个是忘了随机数需要引入random模块别笑我见过至少三个人写完random.randint之后忘记import random然后报错后一脸茫然。4.4 存档系统JSON序列化一行搞定文字冒险游戏有强可暂停性不做存档太可惜了。Python的标准库json干这个活代码量小到可以忽略不计。def save_game(): data { current_scene: player[current_scene], hp: player[hp], inventory: player[inventory], world_exits: {k: v[exits] for k, v in world.items()}, } with open(save.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(游戏已保存。)为什么存档要把world_exits一起存进去因为你的世界里可能有大量可变的门、条件解锁、开关状态。如果不存world的可变部分读档后你明明开过塔门结果又回到锁着状态玩家会非常困惑。存档必须保存整个游戏世界的当前状态而不只是玩家背包这个认知是从小游戏转向大游戏的关键一跃。读取存档时注意文件可能不存在用异常处理天然兜底def load_game(): try: with open(save.json, r, encodingutf-8) as f: data json.load(f) player[current_scene] data[current_scene] player[hp] data[hp] player[inventory] data[inventory] for k, exits in data[world_exits].items(): world[k][exits] exits print(读取存档成功。) except FileNotFoundError: print(没有找到存档文件开始新游戏。)这段代码里比较值得品味的是我用player[hp] data[hp]直接覆盖用的是字典索引方式。有人喜欢用它定义成局部变量再赋值回去那样如果你是player {...}整体覆盖会把你原始字典里的其他字段搞丢。所以记住读档时是把存进去的数据写回原有结构而不是重建一个新的 player 对象。5. 调试心得从能跑到真正能玩代码写出来能跑距离一个真正好玩的游戏还有很长一段路。这一路不是你想象中那种疯狂加功能的激情而是反复玩自己的游戏、被自己的bug气笑、然后一个点一个点修好的耐心活。我最后分享几个这个项目里最典型的坑和习惯。5.1 列举我遇到过的真实大坑我统计了一下带过的学生写这个项目时遇到频率最高的几个坑排个序坑现象根因解法方向词不统一输入north没反应场景出口表里键写的是n统一全部用完整单词小写问题输入GO报错忘记.lower()解析前归一化物品拾取后还在回头再来钥匙还在只 append 没 remove拾取后从地面上删掉存档后世界状态丢失读档塔门又锁了没保存 world 的可变部分连 exits 一起存战斗结束后卡死输了还能继续走没在 hp0 后退出主循环加游戏结束标志我还记得有次我自己调试一个大型项目跑着跑着发现玩家明明在黑猩猩洞穴里捡了石头回到起点再走回来洞穴里的石头又出现了。查了半天发现自己写items.append(item)时忘掉了items.remove(item)——就是这种看起来微不足道的一行直接破坏了游戏的沉浸感。做游戏的人必须跟自己较真自己玩着觉得别扭的地方玩家只会比你更敏感。5.2 自动化试玩把测试指令写进一个列表游戏做完初版你需要反复走流程验证所有路径都没bug。手工输入肯定烦。有个小技巧把常见的测试操作写在一个列表里循环喂给你的主循环函数相当于做一个简单的自动化测试脚本。test_flow [ go north, take lamp, go south, go east, use rusty_key, inventory, help, save, quit ] for cmd in test_flow: print(, cmd) try: output handle(cmd) if output: print(output) except Exception as e: print(f[BUG] 指令 {cmd} 触发异常{e})这么做的好处是你每新增一个功能就把那条新指令也塞进test_flow跑一遍就知道有没有把旧逻辑弄坏。等你想偷懒写脚本实现自动通关它还能顺便帮你检验整个游戏的可解性——确保游戏从初始状态到结局真的能通。5.3 把主循环做成函数化为以后改造留后路我强烈建议你把主循环封装进main()而不是把全部逻辑裸写在文件的顶层。这样你可以随时 import 这个文件方便在交互环境里手动测试某个函数也不用担心一执行就启动了游戏。更重要的是这个游戏逻辑层 在以后做GUI时可以直接被别的主程序调用你只需把input()和print()换成图形界面的接口逻辑一颗边都不用动。所以我始终坚信用函数封装完整逻辑是所有后续改良的前提。5.4 最后一步把游戏从你的电脑搬到所有人做完记得把游戏用PyInstaller打包成exe或者更简单点把.py文件发给朋友让他们在你电脑上用终端跑。你会发现我做的游戏被人亲自玩过和代码跑通了完全是两种成就感。打包其实很简单pip install pyinstaller pyinstaller -F adventure.py-F参数表示打包成单文件如果你是macOS用户生成的也不叫exe而是一个可执行程序。这是文字冒险项目里的最后一课一个项目真正的完成不是在你自己的机器上能跑而是在别人的机器上也能跑。我个人在这个项目上最大的收获其实不是学会了字典嵌套或者json读写而是体会到了用代码构建一个世界的乐趣——那个世界在纸上只是一张表在代码里是一堆字典但在玩家的想象力里它是真实的森林、木屋和高塔。这种转化过程正是编程最迷人的地方。做完这个项目你完全可以在下一个课题里挑战做个小型的图形版本把这里的print换成界面按钮把地图字典换成场景图片——到时候你会发现自己已经有了一个坚固的地基再加什么都顺理成章。
阅读完成 · 觉得有帮助?