很多人一提到“用代码写一个游戏”,第一反应是下载引擎、学渲染、调物理。如果你只是想锻炼编程能力,这条路其实有点绕。今天这篇文章,我想用《宝可梦:百变怪与喵喵的冒险》这个 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 PYTHONIOENCODING=utf-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="撞击", power=40, skill_type="普通", pp=35), "抓": Skill(name="抓", power=40, skill_type="普通", pp=35), "叫声": Skill(name="叫声", power=0, skill_type="普通", pp=40), "变身": Skill(name="变身", power=0, skill_type="普通", pp=10), "火花": Skill(name="火花", power=40, skill_type="火", pp=25), "水枪": Skill(name="水枪", power=40, skill_type="水", pp=25), "飞叶快刀": Skill(name="飞叶快刀", power=45, skill_type="草", pp=25), } 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="百变怪", level=5, max_hp=35, hp=35, attack=8, defense=8, speed=10, types=["普通"], skills={"撞击": skill_lib["撞击"], "变身": skill_lib["变身"]}, ) def create_meowth() -> Monster: return Monster( name="喵喵", level=5, max_hp=40, hp=40, attack=9, defense=7, speed=9, 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"是入口 URL,"next"是跳转路由,"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="喇叭芽", level=3, max_hp=30, hp=30, attack=6, defense=6, speed=8, types=["草"], skills={"飞叶快刀": __import__("data").skill_lib["飞叶快刀"]}, ), "trainer_battle": Monster( name="小火龙", level=5, max_hp=34, hp=34, attack=9, defense=7, speed=9, 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 | 设置PYTHONIOENCODING=utf-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 对手。每加一个功能,都会发现这个骨架的扩展点,这正是学习编程最有意思的地方。