
在终端里敲下python game.py屏幕刷新出一行字——“你站在一间昏暗的石室里火把在墙上噼啪作响南边有一扇虚掩的门东边传来滴水声……”这不是什么3A大作而是完全用Python写出来的文字冒险游戏。文字冒险游戏不靠画面、不靠声光全靠文本、选择与逻辑来承载故事而Python恰恰是这个品类最合适的实现工具语法足够轻标准库够用不用装任何第三方依赖就能把“场景移动、背包系统、战斗规则、剧情分支”完整跑起来。这篇文章我打算带着你从零手搓一个可玩的文字冒险游戏途中会一起拆解场景图设计、游戏主循环、命令解析、json存档这些绕不开的知识点。适合刚学完Python基础语法、想找第一个完整项目练手的人也适合想看看“一个简单游戏内部到底怎么运作”的读者。我会把代码、设计思路、踩过的坑全部摊开讲保证你能照着敲出一份属于自己的可玩Demo。1. 从零规划一个文字冒险游戏1.1 为什么文字冒险游戏是最好的Python练手项目很多初学者学完列表、字典、函数之后会有一种“我啥都会了但啥都做不出来”的空虚感。写爬虫怕被反爬写数据分析要装一堆库写Web又觉得概念太多。文字冒险游戏恰好卡在一个非常舒适的位置它不需要图形界面不需要网络请求不需要复杂算法但又能把Python里最常见的语法点全部串起来。你可以对照着数一数判断用if循环用while和for存物品用列表和字典场景关系本质是一张图随机暴击用random存档要读写文件存文件要序列化成json代码多了要拆函数、拆类、拆模块。一个看似“简陋”的文字游戏实际上把一门语言的入门内容全部覆盖了而且每一样都有直观的产出——你写一个函数游戏里就多一个能用的指令这种正反馈是刷一百道练习题都给不了的。更关键的是文字冒险游戏的逻辑复杂度完全可控。你可以做一条直线的剧情脚本也可以做一张带分支的场景地图还可以升级成带战斗、带任务、带多结局的完整作品。起点低上限高一路都能有东西学。1.2 游戏流程与可扩展玩法设计思路动手写代码之前先想清楚游戏怎么玩。我这边给自己定的目标是做一个“古堡探险”题材的迷你游戏包含以下要素玩家在一个由若干场景组成的地图中移动每个场景有描述文本和可执行操作。场景之间有方向连接比如“石室”的南边通向“大厅”玩家输入go south或s就能移动。地图上有物品可以拾取背包里最多带几件东西特定物品在特定场景触发关键事件。某些场景有怪物玩家可以选择战斗或逃跑战斗是简单的回合制。游戏有一个胜利条件找到钥匙逃出古堡或击败BOSS获得宝藏。玩家死亡后游戏结束也可以随时存档、读档。玩法定下来后我强烈建议你不要一上来就写完整代码。先画一个“场景地图”草图把房间、连接关系、物品、怪物位置全部列出来。我当时的草图画得特别随意就是一张纸写上几个名字用箭头连起来比如“石室 → 大厅 → 武器库”就这么简单的图写代码时帮我省了无数脑力。设计时还要想好“目标”。文字冒险游戏最忌玩家不知道自己要干嘛所以开局必须给玩家一个明确指引比如“你听到远处传来低沉的咆哮古堡的出口被锁住了你需要找到黄铜钥匙。”有了目标后面的代码和文案才不会散。1.3 技术选型为什么只用标准库这个项目我全程只用Python自带的库连第三方依赖都不装。不只是因为懒而是刻意为之。文字冒险游戏的性能压力极小标准库里的random、json、sys完全够用。少掉依赖意味着别人拿到你这份源码只要装了Python 3.8以上就能直接跑省去pip install的一堆事。我用的版本是Python 3.10但项目里没有任何3.10独有的语法你在3.8、3.9上跑也没问题。值得注意的是Python 2已经彻底停止维护不建议再用如果你电脑上python --version显示的还是2.x请先换到3.x再继续。2. 架构设计别把代码写成一坨2.1 数据与逻辑分离的想法“数据与逻辑分离”这七个字听起来很像软件工程课上的废话但在这个项目里它有着非常具体的含义把“世界长什么样”和“世界怎么运行”分成两个部分。世界长什么样指的是场景、物品、怪物、剧情文本这些内容。它本质上是“数据”用字典和列表就能描述。世界怎么运行指的是移动、拾取、战斗、存档这些“行为”它本质上是“逻辑”用函数来承载。好处是极其明显的你想改剧情不需要动逻辑代码直接改字典里的描述文本就行。你想增加新场景往字典里加一个键值对再在连接关系上补一条边就行。哪怕写一个100个场景的大世界逻辑层的代码依然不用大改。反过来如果数据和逻辑混在一起写到最后一定是一团浆糊——改一个剧情要在一堆if里翻半天。我第一次写这类游戏时就是傻乎乎地在循环里堆if结果加三个场景就乱了后来彻底推翻重来才意识到结构的重要性。所以这篇文章里我也会从一开始就按“数据-逻辑分离”的思路带你搭。2.2 用字典描述场景、物品与怪物下面是我最初设计的数据结构核心就是几个大字典。场景用scenes字典存储键是场景ID值是一个包含描述和出口方向的字典scenes { stone_room: { name: 石室, desc: 你站在一间昏暗的石室里墙上挂着一盏将熄未熄的油灯。南边有一扇虚掩的门东边不断传来滴水声。, exits: {south: hall, east: corridor}, items: [lamp], }, hall: { name: 大厅, desc: 这里是大厅头顶的水晶灯已经碎了大半。北边是石室西边是武器库南边是书房。角落里有个生锈的铁箱。, exits: {north: stone_room, west: armory, south: study}, items: [iron_box], }, # ... 更多场景 }物品用字符串ID表示比如lamp、iron_box全部映射到一个items_meta字典里用来存名称和描述items_meta { lamp: {name: 旧油灯, desc: 一盏老旧但还能用的油灯能照亮黑暗的角落。}, iron_box: {name: 生锈铁箱, desc: 一个锁住的铁箱似乎需要钥匙才能打开。}, }怪物设计成字典放在对应场景里的monster字段monsters_meta { shadow: {name: 暗影, hp: 30, min_atk: 6, max_atk: 10}, guard: {name: 古堡守卫, hp: 50, min_atk: 8, max_atk: 12}, }为什么用ID而不是直接把名称、描述写进场景里因为多个场景可能引用同一个物品或怪物。比如“旧油灯”可能在石室出现一次在书房又出现一次。如果用字符串描述散落各处后期想统一改名称就得全局搜索用ID集中管理只改一处就全生效。这个思路跟你数据库里用外键关联表是一样的道理。2.3 玩家状态用一个字典搞定玩家状态我一开始也用了字典包含血量、攻击力、当前场景、背包、已收集的关键标志等player { hp: 100, max_hp: 100, min_atk: 10, max_atk: 15, scene: stone_room, bag: [], flags: {}, # 记录剧情进度比如 {has_key: False} }flags这个字段特别重要。文字冒险游戏的剧情推进本质上是一堆“布尔状态”的切换。比如你捡到了钥匙flags[has_key] True到了大门前判断这个是否为True决定能不能开门。没有这套机制你只能硬编码一堆全局变量写多了自己都分不清哪个是哪个。等代码量上来之后可以把玩家、怪物都改成类。但我建议前期先用字典因为字典足够直观打印出来一目了然出现bug也能很快定位。项目跑通之后再做重构效果更好。3. 核心代码实现一步步把它搭起来3.1 游戏主循环while True 是发动机所有文字冒险游戏的心脏都是一个死循环读玩家的输入 → 解析命令 → 改变游戏状态 → 输出最新描述 → 再读下一个输入。直到游戏结束玩家死亡、通关、输入quit才跳出循环。import sys def main(): print(欢迎来到古堡探险) print(输入 help 查看指令输入 quit 退出游戏。\n) while True: render_game(player) cmd input(\n ).strip().lower() if cmd in (quit, exit): print(你离开了古堡探险结束。) break handle_command(cmd) if __name__ __main__: main()input()是阻塞式读取玩家不输入内容程序就停在这里等这正好符合文字游戏的节奏。读取后我习惯立刻做.strip().lower()作用是把首尾空格去掉、全部转成小写。这样玩家输入GO South和go south会被一视同仁地解析成go south省去很多字符串比较的麻烦。handle_command是命令分发中心相当于一个小型路由器。一般玩家能输入的就那么几种go、look、take、inventory、help、save、load。我建议用字典或者if-elif来分派不要一个超长函数写到地老天荒。def handle_command(cmd): if cmd.startswith(go ) or cmd in (n, s, e, w): move_player(cmd) elif cmd look: look_around() elif cmd.startswith(take ): take_item(cmd.split( , 1)[1]) elif cmd inventory or cmd i: show_inventory() elif cmd help: show_help() elif cmd save: save_game() elif cmd load: load_game() else: print(没听懂你的指令输入 help 查看指令列表。)注意命令解析用startswith而不是直接比较是因为像take key这样带参数的命令前面是动作后面是目标。用split( , 1)拆分1代表只拆第一次出现的空格这样物品名里即使有空格比如old lamp也不会被切碎。3.2 场景地图与移动逻辑一张有向图场景之间的连接关系本质上是一张有向图。每个场景是节点出口方向是边。用字典嵌套表达这种关系非常自然查一次scenes[current][exits][south]就能拿到南边场景的ID。移动逻辑的代码很短但有几个关键点不能漏def move_player(cmd): current_scene player[scene] direction parse_direction(cmd) exits scenes[current_scene][exits] if direction not in exits: print(那边没有路你走不过去。) return target_scene exits[direction] player[scene] target_scene print(f你向{direction}方向走去。) render_game(player) check_monster_and_trigger(target_scene)parse_direction要把go south、s、south统一转化成south。我一般建一个别名映射def parse_direction(cmd): if cmd in (n, north): return north if cmd in (s, south): return south if cmd in (e, east): return east if cmd in (w, west): return west return None这个函数再简单我也建议单独抽出来。因为后面扩展新方向比如up、down时只需要改这一个函数其他代码都不用动。移动成功后还有个容易被忽略的点玩家刚进入一个新场景时光提示“你向南走去”是不够的必须立刻把新场景的描述刷出来。所以我在move_player末尾主动调用了render_game。这个细节直接决定游戏体感——如果没有这步玩家会觉得自己“移动了但画面没变”非常困惑。3.3 物品拾取与背包怎么管好一地东西背包功能我一开始只用了一个列表bag拾取就是append丢掉就是remove。后来发现处理“数量”很麻烦比如药水有时一次拿两瓶。于是改成了“字典列表”的组合背包里存物品ID数量单独记。def take_item(item_name): target_id find_item_id_in_scene(item_name) if target_id is None: print(这里没有这东西。) return if len(player[bag]) 6: print(背包满了放不下更多东西。) return player[bag].append(target_id) scenes[player[scene]][items].remove(target_id) print(f你把{items_meta[target_id][name]}收进了背包。)这里有两个坑我要专门提一下。第一场景里的物品在拾取之后必须立刻从场景列表中移除否则刷新场景时会重复出现同一个物品。我之前偷懒不删结果玩家退回房间再进来油灯又刷出来了整个游戏的逻辑瞬间崩塌。第二查找物品ID时玩家输入的可能是名称“拿旧油灯”而不是ID“拿lamp”所以不能直接比对列表里的ID得先通过items_meta把名称反查成IDdef find_item_id_in_scene(item_name): for item_id in scenes[player[scene]][items]: if items_meta[item_id][name] item_name: return item_id return None这个反查逻辑简单却容易漏新手容易直接拿输入去列表里找找到死也找不到然后跑来问我为什么拿不了东西。真凶多半就是忘了这一层映射。背包装满了要不要限制容量我设了6格纯是为了让玩家面临取舍。文字游戏的策略深度往往不来自战斗而是来自资源管理。背包满了你就得纠结“是丢了油灯拿钥匙还是回头再跑一趟”这个纠结就是游戏性。3.4 战斗系统最简单的回合制战斗是文字游戏里最能让人眼前一亮的模块同时也是新手最容易写崩的地方。我的建议是第一版战斗系统做最朴素的回合制砍掉技能、元素克制、暴击率只保留你一刀我一刀。import random def fight(monster): mname, mhp monster[name], monster[hp] print(f\n一只{mname}挡住了去路) while mhp 0 and player[hp] 0: input(按回车攻击...) player_damage random.randint(player[min_atk], player[max_atk]) mhp - player_damage print(f你挥剑攻击造成{player_damage}点伤害。{mname}剩余{mhp}点生命。) if mhp 0: print(f{mname}倒下了) break monster_damage random.randint(monster[min_atk], monster[max_atk]) player[hp] - monster_damage print(f{mname}反击对你造成{monster_damage}点伤害。你剩余{player[hp]}点生命。) if player[hp] 0: game_over() return False return True这段代码的逻辑很直白先算玩家伤害打完后检查怪物是否死亡如果没死怪物再反击。注意顺序不能反一定要先处理玩家攻击、判定怪物是否死亡再处理怪物攻击否则你可能会遇到“怪物已经死了却还能打你一拳”的诡异场面。随机数的取值范围就是攻击力上下限。我设的玩家攻击10到15怪物血量30到50这样算下来玩家大概3到5刀能砍死一个普通怪物怪物打玩家则是5到8下才能打死。这个数值节奏比较舒适打起来不会让人觉得是纯粹在刮痧也不会一刀秒杀毫无紧张感。如果你想让战斗更有策略可以在第一版跑通之后再加“防御”指令。防御回合减伤50%用input做回合选择。但加指令前务必先确认基础战斗已经稳定不然代码一复杂bug就跟着来了。战斗结束后的掉落也不能少。掉什么我建议写进怪物的loot字段里战斗胜利后自动加入背包if mhp 0: if loot in monster: player[bag].append(monster[loot]) print(f你从{mname}身上搜出了{items_meta[monster[loot]][name]}。) return True这里同样要检查背包容量。或者更简单点把掉落物品直接放在对应场景的地面上玩家想捡就捡不想捡就留着。自由度更高代码麻烦一点二选一都能接受。3.5 存档与读档json是踩坑重灾区文字游戏玩到一半退出下次从头再来这体验实在太糟糕了。所以存档功能是“让游戏真正能玩”的分水岭。Python的json模块完美胜任这个任务因为我的玩家状态、场景物品状态全是字典和列表本质就是可序列化的JSON对象。import json def save_game(): data { player: player, scenes: scenes, } with open(savegame.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(游戏已保存。)注意两点一是encodingutf-8二是ensure_asciiFalse。游戏里全是中文不写这两行存档会变成一串\u77f3\u5ba4之类的转义字符虽然技术上能读出来但你自己打开存档文件想检查时会被直接劝退。写成UTF-8纯中文用记事本也能看排查问题方便得多。读档函数同样要处理“文件不存在”的情况def load_game(): try: with open(savegame.json, r, encodingutf-8) as f: data json.load(f) player.update(data[player]) scenes.update(data[scenes]) print(读档成功继续你的冒险。) except FileNotFoundError: print(没有找到存档文件。) except json.JSONDecodeError: print(存档文件损坏了。)player.update是把读出来的玩家状态整体覆盖到当前player字典上比逐个字段地赋值省事得多。scenes.update同理。用try-except包住读档逻辑防止玩家手滑改了存档导致程序直接崩溃。游戏程序本身可以死存档数据必须稳这是底线。同样的机制也能扩展成多存档位比如savegame_1.json、savegame_2.json玩家选存档位时传个数字进来就行。4. 实操过程从最小可玩版开始迭代4.1 用三个版本把游戏“生”出来第一次写这种项目千万不要想着一步到位。我的做法是把它拆成三个版本每个版本都能跑每个版本都有明确的完成标准。第一个版本我叫它“走路模拟器”。只有场景移动和look查看。你把地图里两三个房间的数据写好能走通就算成功。这个阶段不写战斗、不写背包先把“地图-移动-渲染”这条主线跑通。很多时候主线没跑通就急着叠功能结果连走路都出bug改都不知道从哪改起。第二个版本加入物品和背包。到这一步地图上可以放两三个可拾取的物品拾取后背包能显示能丢弃。这个阶段同时把场景状态变化摸透——拾取后要从场景里移除丢弃后要加回场景。我前面说的那个“物品重复刷新”的坑就是在这个阶段踩出来的。第三个版本才加入战斗和存档。这三件事互相关联有战斗意味着玩家会死会死就需要存档来减少挫败感有存档意味着玩家可以反复读档挑战战斗游戏难度不用调太低。三者形成一个完整的游戏闭环。整体开发在我自己的机器上花了大概一个下午。如果你是从零开始照着这篇文章写我估计两三个小时也能跑通前提是每个版本都不要跳步。4.2 地图与数值设计心得地图设计我强烈建议你先画纸面草图再写进代码。我自己画过的古堡地图大概长这样场景ID场景名称物品怪物stone_room石室旧油灯无hall大厅生锈铁箱无armory武器库长剑古堡守卫study书房日记无dungeon地牢黄铜钥匙暗影treasure宝库黄金雕像最终BOSSgate古堡大门无无连接关系是石室可去大厅和走廊大厅通向武器库、书房武器库通向地牢地牢通向宝库宝库外就是古堡大门。整个地图是一条带分支的路径不算复杂但足够演示所有功能。数值上我给你的经验参考是怪物血量大约是玩家3到5次攻击的总伤害怪物单次攻击伤害控制在玩家总血量的10%上下。这样战斗有压力但不会绝望。掉落物的价值要明显高于战斗风险否则玩家打完没奖励索然无味。4.3 让游戏体感更好的几个小细节代码能跑不等于游戏好玩。有几个零成本但效果拔群的小细节几乎是纯赚体验分。第一在高潮事件前面加一个input()停顿。比如玩家刚进入宝库看到BOSS时先打印一段描述再让他按回车正式开战。这种“阅读-停顿-操作”的节奏是文字冒险游戏控制紧张感的常用手法。第二让“北”“南”等方向指令在开局提示中给出来。很多玩家压根不知道能输入n来移动拿到游戏第一反应是懵的。在help命令里把常用指令写全比在代码里修半天体感要有用得多。第三死亡后不要直接退出给出选项“输入load读档继续或者quit离开。” 这样玩家的挫败感会小很多。我写game_over函数时会打印一句剧情化的死亡描述再进入二次选择效果比干巴巴的“游戏结束”好十倍。5. 常见问题与排查技巧实录5.1 高频问题速查表问题现象可能原因解决方式输入go south没反应命令解析没有区分“动作”和“参数”检查handle_command里是否用了startswith而不是直接相等拾取物品后回到场景又出现了没从场景的items列表中移除物品ID拾取时执行scenes[scene][items].remove(item_id)存档文件全是\u转义字符写文件时没加ensure_asciiFalse在json.dump里加ensure_asciiFalse读档时中文乱码文件编码不统一读写文件时都指定encodingutf-8战斗里怪物死了还攻击攻击判定顺序错误必须先算玩家伤害再判断怪物是否存活跑进一个场景程序无限循环场景数据里的exits指向了不存在的场景ID检查场景ID拼写用脚本遍历所有exits做校验按回车时程序崩溃玩家输入了空字符串split后取不到第二个元素输入后先判断cmd非空再走startswith分支随机掉落每次进游戏都一样没用random.seed但代码里有固定随机流一般不用管若需要“每次不同”正常用random.randint即可这张表里的问题大半是我自己写这个游戏时真实撞过的。其中最经典的就是“怪物死亡了还打人”——逻辑顺序不对看起来像灵异事件实际就是代码少了个return或break。排查这类问题时不要盯着代码猛看直接在关键位置加print调试把每一回合的伤害、血量打印出来问题往往一目了然。5.2 从异常现象反推Bug的实战思路有一次我测试时发现玩家在地牢里想拿“黄铜钥匙”系统提示“这里没有这东西”。我一开始以为物品ID写错了排查了半天最后发现是因为地牢里有两个怪物把物品列表“挤”到了另一个场景拿东西时找错了场景。这类问题用print(scenes[player[scene]][items])一输出就真相大白。所以我建议所有玩家状态变动的地方都留一条调试用的print跑通后再删掉也不迟。另一个高发问题是“游戏卡在战斗循环里出不来”。常见原因是怪物死亡判断写成了while mhp 0但玩家血量掉到0后循环条件仍然满足于是一直打下去。解决方式是循环条件同时判断两方血量while mhp 0 and player[hp] 0循环内再根据双方状态做分支。再往深了说很多教程里会出现“输入s却移动到了东边”的诡异问题十有八九是方向解析函数里的映射写反了。这类纯逻辑错误靠肉眼很难发现建议直接写一个测试函数把每个方向输入都跑一遍自动断言移动结果是否符合预期。哪怕是最笨的测试脚本也比“人肉点一遍”可靠得多。6. 让游戏更进一步重构与扩展6.1 用类重构让代码更接近真实工程当你的地图从10个场景涨到30个函数散落一地的时候就该考虑用类来收拢了。我当时的重构策略是把玩家和怪物各写成一个类把游戏核心逻辑收进Game类。class Player: def __init__(self, name): self.name name self.hp 100 self.max_hp 100 self.min_atk 10 self.max_atk 15 self.scene_id stone_room self.bag [] self.flags {} def take_damage(self, damage): self.hp - damage if self.hp 0: self.hp 0 return self.hp 0class Monster: def __init__(self, monster_id): meta monsters_meta[monster_id] self.name meta[name] self.hp meta[hp] self.min_atk meta[min_atk] self.max_atk meta[max_atk] self.loot meta.get(loot)重构之后调用方式从player[hp] - damage变成了player.take_damage(damage)。初看只是写法变了但类的好处在于可以封装行为。比如take_damage方法内部统一处理了血量下限不用在战斗函数里重复写判断逻辑。怪物的属性全部在构造函数里根据ID自动填好以后加新怪物只需要在monsters_meta里加一条数据写起来就更像“添加内容”而不是“写代码”。这类重构不建议在项目一开始就做。你先用字典把逻辑跑通感受数据的流转再抽象成类会更有感觉。直接上手类的话很多概念是空的容易变成“为了用类而用类”。6.2 剧情选项与多结局让分支真正起作用目前的代码里玩家指令是自由输入。如果你想做更传统的“选项式”文字冒险可以在场景描述后直接列出1、2、3三个选项用input接收数字def show_choice(scene_id): choices scenes[scene_id].get(choices, []) for i, choice in enumerate(choices, start1): print(f{i}. {choice[text]}) num input( ) idx int(num) - 1 return choices[idx][target], choices[idx][flag_set]这种分支结构的核心是choices数据里包含跳转目标和要修改的flags。比如选择“推开棺材”它可能设置了flags[opened_coffin] True同时把场景切换到“地下密室”。后续某个场景的解锁条件就依赖这个flag。多结局的本质就是“多个flag的组合决定最终走向”和真实的游戏开发思路是一模一样的。6.3 从命令行走向图形界面的方向当你的文字游戏逻辑稳定后想加点视觉表现有几个循序渐进的方向。第一优先级是给输出文本加颜色。用 ANSI 转义序列或者colorama库把对话文本、物品名称、伤害数字渲染成不同颜色。这个改动成本极低收益却非常直观能让玩家一眼分清什么是描述、什么是物品、什么是战斗信息。第二优先级是做一个简单的图形界面。Python 里最省事的选择是tkinter它是标准库自带不需要额外安装。依然不用改核心逻辑只要把print换成往窗口文本框里追加文本把input换成输入框回车事件就行。再到后面还可以用pygame做环境渲染但那个已经离开“文字冒险”的范畴了。我个人的建议是先别急着上图形界面。文字冒险游戏的核心体验在文本与选择本身你花时间把剧情写精彩、把地图设计得有探索感比套一层花哨界面重要得多。图形界面是锦上添花剧本才是立身之本。最后分享一点个人经验我自己的习惯是先把一个完整流程跑通再回头填充细节。这套“主循环 字典数据 函数逻辑”的骨架不光是文字冒险游戏能用很多命令行交互工具、文本查询系统也都是这个路数。你把这个项目啃透会发现自己对“程序的组织方式”有了更具体的认知。写到这里我回想起当初第一次在终端里跑通古堡探险时看见屏幕上一段段文字随着输入展开仿佛真的在探索一个世界。那份成就感不亚于后来写过的任何大项目。最后再分享一个小细节游戏项目里一定要把故事文本和代码分开存放不只是为了维护方便更是为了让你自己愿意一遍一遍读下去——读得下去你才有动力把一个想法真正做完。