
很多人一提到“用代码写一个游戏”第一反应是下载引擎、学渲染、调物理。如果你只是想锻炼编程能力这条路其实有点绕。今天这篇文章我想用《宝可梦百变怪与喵喵的冒险》这个 Python 小项目完整地演示一个回合制 RPG 是怎么从零搭起来的。这里的“全本”不是复刻官方剧情而是指把一个最小的完整冒险闭环打通有精灵数据、有属性克制、有回合制战斗、有剧情分支、有可运行入口。你会发现游戏开发里真正难的不是“做画面”而是把一堆规则用数据驱动的方式组织起来。读完本文你会拿到一个可以直接运行的 Python 项目骨架并且知道每个模块为什么这样设计。先给结论这篇文章的价值不在于“我又写了一个命令行游戏”而在于三个可迁移的技能点。第一数据与逻辑分离——精灵、技能、剧情脚本都放在独立的数据结构里不写死在 if/else 中。第二状态机思维——回合制战斗就是一个典型的有限状态机。第三原型迭代方法——先用文本界面把核心机制跑通再考虑图形化。很多商业游戏项目也是同一个路子。1. 为什么选用“百变怪和喵喵”这个案例如果只看表面百变怪是一只能变形的宝可梦喵喵是一只会用聚宝功的普通系宝可梦它们凑在一起更像一个搞笑冒险组合。但从编程角度看这两个角色刚好覆盖了两类非常典型的设计难点。百变怪的核心机制是“变身”。在战斗里变身意味着什么意味着一个对象在运行过程中要动态修改自己的属性、属性和技能列表。这在业务系统中非常常见用户切换角色、订单切换状态、任务切换执行器本质都是运行时状态迁移。处理百变怪的变身逻辑比写一百个 if/else 更能让人理解“什么才是好的对象设计”。喵喵则代表最传统的物理输出型角色血不高、攻不低、技能槽有限。它让玩家必须认真考虑技能搭配和回合策略。它的聚宝功、抓、叫声这些技能又涉及“技能可能没有伤害只产生特殊效果”的情况。这个点在战斗引擎中很容易被忽略但真实游戏里非常重要。还有一个现实原因宝可梦题材自带一套清晰易懂的规则。属性克制表、速度决定先手、技能有威力与命中这些规则不需要我额外解释读者一看就懂。用熟悉规则去理解陌生代码学习成本会低很多。从这个项目中你能迁移到的不只是游戏逻辑。比如订单状态机、审批流节点、规则引擎的配置化甚至自动化测试中的“模拟用户操作路径”都和这里的实现思路高度相似。所以这篇文章虽然标题看起来像“游戏项目”实际上讲的是如何用 Python 写一个可扩展的规则系统。2. 核心原理数据驱动、状态机与属性克制在正式写代码前先把三个核心设计原理讲清楚。很多新手项目的最大问题就是所有内容都堆在 main 函数里遇到什么怪写一段逻辑拿到什么道具写一段逻辑。这样的代码在只有两个精灵时没问题但一旦扩展到十几只精灵代码会迅速失控。数据驱动的意思是把“有哪些精灵”“每个精灵会哪些技能”“技能威力是多少”“属性之间如何克制”这些信息全部抽成数据表。战斗引擎和剧情引擎只负责读取这些数据然后执行通用规则而不关心具体的精灵是谁。也就是说将来你要加一只新精灵不需要改战斗代码只需要加一段数据。状态机是回合制战斗的骨架。一场战斗可以拆成选择技能状态、执行攻击状态、检查胜负状态、切换回合状态。每一步都是一个确定性的状态迁移不满足条件就不能进入下一步。这是非常标准的有限状态机模型也是后端开发中任务调度的常见抽象。属性克制则是典型的二维矩阵数据。火打草效果拔群水打火效果拔群草打水效果拔群。数据可以表达为字典的元组键(火, 草)对应倍率 2.0。代码本身不关心宝可梦世界到底有几套属性它只负责“查表、取倍率、参与伤害计算”。理解了这三个原理后面的代码其实就是把这些思想翻译成 Python。数据表用字典和 dataclass状态迁移用 while 循环加条件判断属性克制用带元组键的字典。没有任何黑魔法全部都是基础语法。3. 环境准备与项目结构这个项目不依赖任何第三方库只需要 Python 3.9 及以上版本。为什么选 3.9不是因为低版本不能用而是为了用上list[str]这类内置泛型语法写起来更简洁。如果你环境里只有 Python 3.8也可以把类型注解改成List[str]代码照样能跑。项目结构建议这样组织pokemon_adventure/ ├── data.py # 精灵、技能、属性克制数据 ├── monster.py # 数据模型 dataclass ├── battle.py # 战斗引擎 ├── story.py # 剧情脚本 ├── main.py # 程序入口 └── README.md # 项目说明每个文件只负责一件事。data.py 是数据库monster.py 是数据结构定义battle.py 是战斗规则story.py 是剧情脚本main.py 负责把它们串起来。运行项目只需要在项目目录执行python main.py不需要 pip install也没有其他依赖安装步骤。这样设计的目的是让读者把精力完全放在逻辑理解上而不是环境问题。如果你使用的是 Windows 系统并且运行后出现中文乱码可以在命令行临时设置编码set PYTHONIOENCODINGutf-8 python main.py在 macOS 和 Linux 上通常没有这个烦恼。4. 数据建模精灵、技能与属性克制表数据模型是整个项目的底座。先定义技能和精灵两个 dataclass。技能需要名称、威力、属性和 PP 值精灵需要名字、等级、血量、攻防、速度、属性和技能字典。# monster.py from dataclasses import dataclass from typing import Dict, List dataclass class Skill: name: str power: int skill_type: str pp: int dataclass class Monster: name: str level: int max_hp: int hp: int attack: int defense: int speed: int types: List[str] skills: Dict[str, Skill]这里有一个容易忽略的细节types用列表而不是字符串。虽然绝大多数宝可梦只有一个属性但从设计角度必须支持双属性。双属性在战斗中会同时参与属性克制计算比如“水 飞行”的属性被电属性攻击时可能造成更高伤害。提前用列表模型化后续扩展时不会重构。技能库和精灵工厂函数放在 data.py。为了简单先创建四个技能撞击、抓、叫声、变身。有了技能库之后用函数创建百变怪和喵喵而不是直接写大量重复数据。# data.py from monster import Monster, Skill skill_lib { 撞击: Skill(name撞击, power40, skill_type普通, pp35), 抓: Skill(name抓, power40, skill_type普通, pp35), 叫声: Skill(name叫声, power0, skill_type普通, pp40), 变身: Skill(name变身, power0, skill_type普通, pp10), 火花: Skill(name火花, power40, skill_type火, pp25), 水枪: Skill(name水枪, power40, skill_type水, pp25), 飞叶快刀: Skill(name飞叶快刀, power45, skill_type草, pp25), } TYPE_CHART { (火, 草): 2.0, (火, 水): 0.5, (水, 火): 2.0, (水, 草): 0.5, (草, 水): 2.0, (草, 火): 0.5, (电, 水): 2.0, (电, 草): 0.5, } def create_ditto() - Monster: return Monster( name百变怪, level5, max_hp35, hp35, attack8, defense8, speed10, types[普通], skills{撞击: skill_lib[撞击], 变身: skill_lib[变身]}, ) def create_meowth() - Monster: return Monster( name喵喵, level5, max_hp40, hp40, attack9, defense7, speed9, types[普通], skills{抓: skill_lib[抓], 叫声: skill_lib[叫声]}, )TYPE_CHART 是这个项目里最像“规则配置”的地方。它本质是一个二维矩阵但因为字典的元组键写法比二维数组更直观所以被广泛使用。这里的倍率规则非常简化只写了本案例会用到的几组克制关系实际项目可以根据需要补全。将“数据”与“对象实例”分开的意义在于以后加一个皮卡丘只需要在create_pikachu里填入电属性技能战斗引擎不需要变动。这才是真正可扩展的设计。5. 战斗引擎实现回合制对战与胜负判定战斗引擎是整个项目中最具复用价值的部分。它要解决三个问题伤害怎么算、谁先出手、技能效果怎么处理。伤害公式参考了经典角色扮演游戏的思路基础伤害由攻击、防御、技能威力共同决定最后乘上属性克制倍率和随机浮动值。这个公式不需要很精确但一定要能体现“攻高打防低更疼”和“属性克制明显影响战局”这两个基本直觉。# battle.py import random from monster import Monster, Skill from data import TYPE_CHART def calculate_damage(attacker: Monster, defender: Monster, skill: Skill) - int: if skill.power 0: return 0 base ((attacker.attack * skill.power / defender.defense) / 50 2) modifier 1.0 for atype in attacker.types: for dtype in defender.types: modifier * TYPE_CHART.get((atype, dtype), 1.0) damage int(base * modifier * random.uniform(0.85, 1.0)) return damage这个函数的关键不是公式本身而是“查表”逻辑。它遍历攻击方和防守方的所有属性组合从 TYPE_CHART 里取倍率。如果属性关系不在表中就用默认倍率 1.0。这样即使后面加入龙系、钢系等复杂属性也不需要改这段代码只需要扩展数据表。接着实现攻击函数。这里要特别注意技能威力为 0 不代表技能没用。叫声能降低对手攻击变身能让百变怪复制对手的数据。所以执行技能前必须先判断技能名称和特殊逻辑。def transform_logic(actor: Monster, target: Monster) - None: actor.types target.types[:] actor.attack target.attack actor.defense target.defense actor.speed target.speed actor.skills dict(target.skills) def attack(actor: Monster, target: Monster, skill: Skill) - None: if skill.name 变身: transform_logic(actor, target) print(f{actor.name} 变身成了 {target.name}) print(f{actor.name} 的属性变成了 {actor.types}技能变成了 {list(actor.skills.keys())}。) return if skill.power 0: print(f{actor.name} 使用了 {skill.name}。) return damage calculate_damage(actor, target, skill) target.hp - damage print(f{actor.name} 对 {target.name} 使用 {skill.name}造成 {damage} 点伤害。)变身逻辑之所以单独抽出来是因为它修改了 actor 的属性数组和技能字典。如果不注意“复制 skills 而不是引用”可能会出现一个精灵变身导致另一个精灵技能也改变的情况。这里的dict(target.skills)和target.types[:]就是做浅拷贝保证两个对象之间不互相干扰。战斗主循环使用 while 循环直到某一方血量归零。先手顺序通过比较速度决定速度相同则玩家优先这是对玩家友好的约定。AI 方随机选择一个可用技能玩家方从终端输入技能编号。def choose_skill(me: Monster) - Skill: names list(me.skills.keys()) print(可用技能) for i, name in enumerate(names, 1): print(f{i}. {name}) while True: raw input(请选择技能编号: ).strip() if raw.isdigit() and 1 int(raw) len(names): return me.skills[names[int(raw) - 1]] print(输入无效请重新输入。) def start_battle(player: Monster, enemy: Monster) - bool: print(f野生的 {enemy.name} 出现了) while player.hp 0 and enemy.hp 0: actors [player, enemy] if player.speed enemy.speed else [enemy, player] for actor in actors: if player.hp 0 or enemy.hp 0: break target enemy if actor is player else player if actor is player: skill choose_skill(player) else: skill random.choice(list(enemy.skills.values())) attack(actor, target, skill) if player.hp 0 and enemy.hp 0: print(f{player.name}: {player.hp}/{player.max_hp} {enemy.name}: {enemy.hp}/{enemy.max_hp}) input(按回车进入下一回合...) if player.hp 0: print(战斗胜利) return True print(战斗失败...) return False这里用了actor is player而不是actor.name player.name。因为对象可能重名但身份比较应该用对象身份而不是字符串。这是很多新手容易踩的坑。战斗引擎还有一个重要细节PP 值虽然没有在战斗主循环里强制扣减但在数据模型中已经预留。实际项目中每次使用技能都应该判断 PP 是否大于 0否则技能不能使用。为了避免这篇文章过于冗长这里先不展开但它应当放在扩展清单中。6. 冒险剧情实现基于脚本的事件推进游戏的另一大块是剧情。如果把剧情也写成 if/else例如“如果玩家进入森林则打印森林描述如果玩家选择右边则进入湖泊”那当剧情分支多起来后会非常痛苦。正确做法是把剧情定义成一张“场景表”。每个场景包含显示文本、可选项列表、每个选项对应的下一个场景。部分场景还包含战斗事件战斗胜利后进入下一步。# story.py STORY { start: { text: 你拿着精灵球走出家门遇到了在路边玩耍的百变怪。\n百变怪跳上你的肩膀表示想和你一起冒险。, choices: [(1, 让它加入), (2, 犹豫一下)], next: {1: meet_meowth, 2: hesitate}, }, hesitate: { text: 你犹豫了一会儿决定还是带上百变怪一起走。, choices: [(1, 继续前进)], next: {1: meet_meowth}, }, meet_meowth: { text: 路边忽然窜出一只喵喵叼着一枚金币对你发出喵喵的叫声。\n它看起来想加入队伍。, choices: [(1, 邀请喵喵一起冒险)], next: {1: forest_entry}, }, forest_entry: { text: 你和百变怪、喵喵一起进入了青木森林。前方草丛里传来窸窣声。, battle: wild_grass, choices: [(1, 继续深入森林)], next: {1: forest_clearing}, }, forest_clearing: { text: 森林深处有一片空地你遇见了一位正在训练的少年。\n少年提出要和你来一场对战。, battle: trainer_battle, choices: [(1, 结束今天的冒险)], next: {1: end}, }, end: { text: 冒险暂告一段落。你带着两只精灵回到了家中把它们介绍给家人。, choices: [], next: {}, }, }这个脚本表非常像后端路由表start是入口 URLnext是跳转路由battle是拦截器。这种模式在游戏开发、配置化工作流、低代码平台中非常常见。剧情驱动函数需要做三件事根据当前场景名读取场景数据打印文本如果场景配置了战斗则先触发战斗战斗胜利后才能继续选择。# story.py from battle import start_battle from data import create_meowth, create_ditto from data import Monster def create_enemy(name: str) - Monster: enemies { wild_grass: Monster( name喇叭芽, level3, max_hp30, hp30, attack6, defense6, speed8, types[草], skills{飞叶快刀: __import__(data).skill_lib[飞叶快刀]}, ), trainer_battle: Monster( name小火龙, level5, max_hp34, hp34, attack9, defense7, speed9, types[火], skills{火花: __import__(data).skill_lib[火花]}, ), } return enemies[name]上面这个敌人构造方式有一个明显的坏味道它用__import__(data)来访问技能库可读性很差。更好的写法是在 story.py 顶部直接导入 skill_lib。这里为了展示一种“不要这样做”的反例刻意写成了动态导入。读者在实际项目中应该把敌人数据也放到 data.py 中统一管理。剧情主循环使用while scene:来推进。当场景名为空字符串或 None 时循环自然结束。def run_story(team) - None: scene start while scene: content STORY[scene] print(content[text]) if battle in content: print(战斗开始) enemy create_enemy(content[battle]) result start_battle(team[0], enemy) if not result: print(你失去了战斗故事结束了。) break for m in team: m.hp m.max_hp if not content[choices]: break for key, desc in content[choices]: print(f{key}. {desc}) choice input( ).strip() next_scene content[next].get(choice) if next_scene: scene next_scene else: print(输入无效请重新选择。)战斗结束后恢复全队血量是一种典型的“剧情模式”处理方式。真实游戏里可能只是回到宝可梦中心但这里为了演示流程而简化。这个处理逻辑让我想强调demo 可以用简化规则但简化规则要注释清楚方便以后替换成完整规则。7. 游戏入口 main.py 与运行验证入口文件负责初始化玩家队伍然后调用剧情函数。# main.py from data import create_ditto, create_meowth from story import run_story def main() - None: print() print( 《宝可梦百变怪与喵喵的冒险》Python 文本版) print() team [create_ditto(), create_meowth()] print(f队伍组建完成{, .join(m.name for m in team)}) run_story(team) print(感谢游玩再见) if __name__ __main__: main()在项目目录执行python main.py预期效果如下 《宝可梦百变怪与喵喵的冒险》Python 文本版 队伍组建完成百变怪, 喵喵 你拿着精灵球走出家门遇到了在路边玩耍的百变怪。 百变怪跳上你的肩膀表示想和你一起冒险。 1. 让它加入 2. 犹豫一下 1接着进入下一段剧情直到遇到野生宝可梦后进入战斗流程。你可以在战斗中选择“变身”或“撞击”观察百变怪变身后的数据和普通攻击的差异。判断运行成功有三个标准剧情能一步步从 home 推进到森林战斗能正常进行输入技能编号后能输出伤害信息百变怪使用“变身”后技能列表会变成对方的技能列表。如果这三个现象都出现就说明核心链路已经跑通。如果运行失败第一步不是改代码而是看报错信息。大部分问题通常是缩进错误、类型注解不兼容、或者控制台编码问题。常见问题在下一节单独列出。8. 常见问题与排查思路把最容易遇到的坑整理成一张排查表方便收藏复用。问题现象可能原因排查方式解决方案中文乱码Windows 控制台默认编码不是 UTF-8观察错误信息是否包含 UnicodeEncodeError设置PYTHONIOENCODINGutf-8后运行程序报NameError函数或变量拼写不一致查看报错行号检查调用名统一命名避免中英文混用变身之后技能没变化直接修改了技能字典引用打印变身前后 skills 的 id使用dict(target.skills)拷贝伤害总是最小值属性克制的表 key 与精灵 types 不一致打印 attacker.types 和 defender.types确认属性表使用完全相同的大小写剧情推进卡住next字典缺失对应的选择键打印content.keys()检查剧情场景 ID 是否一致战斗后 HP 没有恢复没有在战斗结束后手动回血检查剧情循环里是否执行m.hp m.max_hp在战斗结算后统一处理输入数字后无反应input 读到了换行或空格用repr(raw)打印输入内容先执行strip()再判断技能威力为 0 但不想消耗回合没有特殊技能分支在 attack 中增加if skill.name 变身对特殊技能单独处理代码复制后报缩进错误Markdown 复制时把空格转成了 Tab 或丢失缩进用 Python 解释器重新编译直接下载源码或在 IDE 中自动格式化想加新精灵但代码到处改精灵创建逻辑散落在多个文件检查创建函数是否集中在 data.py把新精灵创建函数统一放回 data.py这些坑大多数不是逻辑太难而是数据没有规范化导致。如果精灵属性、技能名、技能类型、场景 ID 全部用统一命名规范至少能减少一半问题。9. 工程化建议与后续扩展项目骨架跑通以后下一步不是急着加更多精灵而是考虑工程化和可维护性。第一把数据类型和工厂函数拆得更彻底。目前 data.py 里的 TYPE_CHART 还是一个纯字典如果属性关系越来越多建议换成二维数组或者 JSON 文件加载。把“数据”和“代码”完全分离能让策划或数据配置人员不碰代码也能调参数。第二引入简单存档。通过 JSON 保存当前场景、队伍 H 和背包重启游戏后从存档继续。这个功能操作量不大但能体现“状态持久化”的概念。需要注意保存前要把 dataclass 对象转换为可序列化的字典加载时再还原。第三增加技能效果系统。现在的“叫声”只打印文本没有实际效果。正式项目中技能效果可以用一个effects字段描述比如{target: enemy, stat: attack, stage: -1}再用一个通用效果解析器去执行。这是把“数据驱动”再向前推一步的关键设计。第四补充自动化测试。战斗引擎非常适合写单元测试给定特定攻击方、防御方和技能断言伤害值是否落在合理区间。测试能防止改一个数据导致整个战斗失衡。一个小建议伤害公式涉及随机数测试时要 mock 掉随机函数或者直接测试“基础伤害”公式而不是完整伤害。第五从代码仓库的角度建议给这个项目加一个简单的 README记录玩法说明和扩展计划。即使是一个练习项目也能让你提前养成文档习惯。最后想说一点关于素材边界的提醒。宝可梦是受版权保护的游戏 IP本文项目仅用于学习和演示。如果你真的想发布一个完整游戏建议使用原创精灵模型或者只保留“回合制 属性克制”这套玩法机制而不使用官方角色的美术素材和世界观文本。玩法机制本身不受版权保护但角色形象和名称不能随意商用。做一个学习项目没有问题发布到应用商店则需要谨慎。这个项目骨架的价值在于它证明了一个核心观点游戏逻辑并不复杂复杂的是规则之间的耦合。把规则变成数据表把流程变成状态机把角色做成可复制的对象这三件事一旦想明白不止游戏开发能用很多业务系统开发也通用。下一步你可以试着加入第三只精灵、设计一个新剧情分支、或者写一个简单的电脑 AI 对手。每加一个功能都会发现这个骨架的扩展点这正是学习编程最有意思的地方。