☰
用游戏角色系统实战Python面向对象:class、继承、多态一次搞懂
2026/10/10 5:05:41 网站建设 项目流程

很多人学Python学到面向对象这一章就卡壳了:class、self、__init__、继承、多态这些概念单独看都能看懂,合在一起就不知道它们到底在解决什么问题。平时写的脚本从上往下跑,变量和函数一多完全没头绪,一到真正的项目里就掉头发。

我的建议很简单:别死啃语法,直接拿一个小游戏角色系统练手。角色、技能、血量、组队、打怪,这些游戏里的天然概念和面向对象编程(OOP)的套路几乎是完美对应的。把这个系统写出来,你会突然发现class不是考试名词,而是帮你管理复杂代码的工具。这篇文章就带你从零搭一套带英雄、怪物、回合制战斗的角色系统,代码可直接运行,能动手跑一遍的项目比看十遍教程管用。

1. 为什么要用游戏角色系统讲Python面向对象

1.1 函数式写法的痛苦:数据与逻辑在打架

先回忆一下没有class的时候,我们怎么描述一个角色。最常见的做法是拿字典装属性:

hero = {"名字": "阿伟", "血量": 100, "攻击": 20, "防御": 5} monster = {"名字": "史莱姆", "血量": 50, "攻击": 10, "防御": 2}

然后写一堆函数去操作这些字典:

def attack(attacker, target): damage = attacker["攻击"] - target["防御"] target["血量"] -= damage return damage

这个写法在只有一个英雄、一个怪物时挺清楚。可一旦角色多了,比如队伍里有五个角色、敌人也有五种,每个角色还得带技能、经验值、升级、装备,你会发现:

  • 每个函数都要小心翼翼地记住字典里有哪些键,改错一个键名就全线崩盘。
  • 要加一个“施法消耗魔法”的功能,你得在十几个函数里同步修改逻辑。
  • “谁这个怪物”完全靠字典的数字状态去推断,代码根本没法表达“这个角色还活着么”“能不能用技能”这层业务含义。

最难受的是,数据和操作数据的方法是分离的两堆代码,一旦规模大一点,人脑根本记不住它们之间的关联。游戏里那么多个对象,每个对象有自己的状态、自己的行为,用函数一路平铺下去,写出来的东西就是一堆纠缠在一起的线头。

1.2 面向对象的核心思路:把数据和行为捆成整体

面向对象编程做了一件看起来非常朴素的事:把某个概念的数据(血量、攻击、等级)和行为(攻击、被攻击、升级)放在一起,封装成一个“对象”。

你不需要记住“哪个字典是哪个角色的”,你拿到一个英雄实例,它自己就知道自己叫啥、有多少血、会什么招式。想让它打人,直接调用它的方法就行;想让怪物打它,直接调用怪物的方法。至于内部状态如何变化,那由对象自己负责,外部代码不用管,也没法随手乱改。

用一个生活化的类比来感受一下:一辆汽车是一个对象,油表、车速是它的属性;踩油门、刹车是它的方法。你驾驶汽车时不用自己拧开油箱盖手动算速度,你只需要踩踏板,车自己根据燃油量决定输出多少动力。把油门逻辑藏进车身里,对外统一呈现出“踩油门就提速”的接口,这就是“封装”的直观感受。

当我们讨论完这个概念之后,需要承认一个事实:游戏本身就是由一大堆对象构成的,角色是对象、怪物是对象、道具是对象、技能是对象。拿游戏场景讲解面向对象,基本上就是在用母语教学。

2. 先把这几个容易绕晕的概念给破了

2.1 类、实例、self到底是谁

很多初学者卡在这里,核心是没理解self为什么不请自来。

先定义一下:class是画图纸,实例是根据图纸造出来的那辆车。图纸可以反复使用,同一个类可以造无数个实例。每个实例的数据是独立的,但方法代码是共享的——你不可能给每辆车都重新造一套发动机图纸吧?

那么问题来了:方法代码是共享的,attack(self, target)这个方法在调用hero.attack(monster)的时候,Python怎么知道该攻击谁、谁受到了攻击?

答案就是self。调用hero.attack(monster)时,Python会偷偷在参数列表最前面塞一个hero对象的引用,变成attack(hero, monster)。所有写在方法里的self.xxx其实都是在说“当前这个实例的xxx”。

self不是语法糖,它是对象方法里那个“被操作对象本身”的占位符。很多新手在方法里忘了写self.,去直接操作一个局部变量,结果就是报错或者改错数据,原因就在这里。

2.2 __init__不是构造函数,要叫它初始化器

网上很多教程说__init__是构造函数,这个说法其实不准确。Python里真正负责创建实例的是__new__,__init__只负责在对象创建之后填充初始状态,相当于房子已经砌好了,你进去摆家具、刷墙。

至于为什么方法名前后要双下划线,这是Python的约定写法,表示“魔法方法”:Python解释器在特定场景会自己调用它们。__init__就是那个场景——当你写Warrior("阿伟", 120, 18, 8)时,Python自动分配好一个空壳对象,然后调用它的__init__方法,把参数传进去。

2.3str让对象说人话

先做个小实验,定义好一个类之后直接打印实例:

print(hero)

你多半会看到一行<__main__.Warrior object at 0x000001F0A2C1A5D0>,这其实是一个对象的默认身份信息:类名加它在内存里的地址。

这么看很不直观。你更希望打印英雄时,直接看到“阿伟,HP 120/120,攻击 18”这样的描述。这时就需要重写__str__方法:

def __str__(self): return f"{self.name}:HP {self.hp}/{self.max_hp},攻击 {self.atk}"

print一个对象的时候,Python会自动调用它的__str__方法来决定要打印什么内容。这是一个低成本、高收益的习惯:把对象以人类可读的方式展示出来,你能省掉大量调试时间——黑盒调试永远不如直接看输出直观。

3. 动手搭建一套可运行的游戏角色系统

3.1 先造一个万能角色基类Character

我建议先从基类入手。所谓基类,就是“所有角色都有的共性”。无论战士还是法师,它们都有名字、血量、攻击力、防御力,都会被攻击、会打别人、会升级,这些共同点集中写在一个Character类里。

import time class Character: # 类属性:所有角色共享,可以直接通过 类名.属性名 访问 GAME_NAME = "Python小小世界" def __init__(self, name, hp, atk, defense): self.name = name # 实例属性 self.max_hp = hp self.hp = hp self.atk = atk self.defense = defense self.level = 1 self.exp = 0 def is_alive(self): """判断角色是否存活""" return self.hp > 0 def take_damage(self, damage): """承伤逻辑:防御结算,至少扣1点血""" actual = max(1, damage - self.defense) self.hp = max(0, self.hp - actual) return actual def attack(self, target): """普通攻击""" if self.is_alive() and target.is_alive(): actual = target.take_damage(self.atk) print(f"{self.name} 攻击 {target.name},造成 {actual} 点伤害") return actual return 0 def gain_exp(self, amount): """获得经验并判断升级""" self.exp += amount need = self.level * 100 if self.exp >= need: self.exp -= need self.level += 1 self.max_hp += 10 self.hp = self.max_hp self.atk += 2 self.defense += 1 print(f"【{self.name} 升级了!】当前等级 {self.level},属性已提升") def __str__(self): return f"{self.name} [Lv.{self.level}] HP {self.hp}/{self.max_hp} 攻 {self.atk} 防 {self.defense}"

这里有几个值得说透的细节:

  • self.max_hp和self.hp都从同一个构造参数复制出来,目的是让“血量上限”和“当前血量”成为两个独立的量。战斗中只会改当前血量,恢复满血时知道回到哪个上限。
  • take_damage中用了max(1, damage - self.defense),一方面是防御结算,另一方面强制最少扣1点血。不这么做的话可能出现“怪物防御比英雄攻击还高,血条怎么打都打不动”的死循环。
  • hp = max(0, self.hp - actual)这一行把血量限制到非负,保证输出好看,状态也干净。
  • 类属性GAME_NAME不属于任何单独实例,所有角色共享同一份。访问方式可以是Character.GAME_NAME也可以是hero.GAME_NAME,但改的时候建议用类名去改,原因后面讲。

3.2 用继承造出战士和法师

继承解决的是“共性和差异并存”的难题。共性放在基类里,差异放在子类里。战士和法师都继承Character,于是它们天生就有血量、攻击、升级这些能力,只需要自己补上独有的东西。

战士加一个怒气系统,攒怒气槽,攒够30点能释放重击,伤害更高:

class Warrior(Character): """战士:额外拥有怒气机制""" def __init__(self, name, hp, atk, defense): super().__init__(name, hp, atk, defense) self.rage = 0 self.max_rage = 100 def gain_rage(self, amount): """受到伤害或攻击时会积攒怒气""" self.rage = min(self.max_rage, self.rage + amount) def use_skill(self, target): """重击:消耗30怒气,打出1.5倍伤害""" if self.rage < 30: print(f"{self.name} 怒气不足,当前怒气 {self.rage}/100,破不了防") return 0 self.rage -= 30 damage = int(self.atk * 1.5) actual = target.take_damage(damage) print(f"{self.name} 释放重击,造成 {actual} 点伤害,剩余怒气 {self.rage}") return actual

法师加一个法力条和火球术,法力耗光就只能普攻:

class Mage(Character): """法师:额外拥有法力值,可以用火球术""" def __init__(self, name, hp, atk, defense, mp): super().__init__(name, hp, atk, defense) self.mp = mp self.max_mp = mp def use_skill(self, target): """火球术:消耗20魔法,打出2倍伤害""" if self.mp < 20: print(f"{self.name} 法力不足,当前法力 {self.mp}/{self.max_mp}") return 0 self.mp -= 20 damage = self.atk * 2 actual = target.take_damage(damage) print(f"{self.name} 施放火球术,造成 {actual} 点伤害,剩余法力 {self.mp}") return actual

这两个子类里最关键的一行是super().__init__(name, hp, atk, defense),意思是先把父类的初始化逻辑跑一遍,让角色先拥有名字、血量、攻击力这堆基础属性,然后自己再加怒气或法力。如果不调用它,你会发现子类对象连hp都没有,因为父类__init__里那几行赋值逻辑根本不会自动执行。

子类里重写了use_skill,这就是多态的开始:同为“放技能”这个动作,战士和法师的行为完全不同。只要外部代码调用这个接口,Python运行时就会自动找到对应子类的方法版本继续执行,不需要写if 是战士 then ... elif 是法师 then ...这种烂判断。

3.3 写一个怪物类和回合制战斗流程

怪物其实也是角色。为了演示“哪怕没有继承关系,只要方法接口一致就能用”,我给怪物单独写一个类,方法名和Character保持一致:

class Monster: """野怪:与角色类没有继承关系,但接口保持一致(鸭子类型)""" def __init__(self, name, hp, atk, defense): self.name = name self.max_hp = hp self.hp = hp self.atk = atk self.defense = defense def is_alive(self): return self.hp > 0 def take_damage(self, damage): actual = max(1, damage - self.defense) self.hp = max(0, self.hp - actual) return actual def attack(self, target): if self.is_alive() and target.is_alive(): actual = target.take_damage(self.atk) print(f"{self.name} 攻击 {target.name},造成 {actual} 点伤害") return actual return 0 def use_skill(self, target): # 怪物没有技能,直接普攻 return self.attack(target) def __str__(self): return f"{self.name} [野怪] HP {self.hp}/{self.max_hp} 攻 {self.atk} 防 {self.defense}"

战斗流程我设计成回合制:玩家有回合内选择权,怪物没有。用isinstance判断当前对象类型,就能给出对应的技能选项。这是最直观、最容易读懂的写法,适合新手。后面讲到工程化时,我会提供更优雅的改造思路。

def battle(player, enemy): """回合制战斗,玩家可选普攻或技能""" print(f"遭遇战:{player} VS {enemy}") round_num = 1 while player.is_alive() and enemy.is_alive(): print(f"\n----- 第 {round_num} 回合 -----") print(f"玩家状态:{player}") print(f"敌人状态:{enemy}") action = input("选择操作:1=普通攻击,2=技能:").strip() if action == "2": if isinstance(player, Warrior): player.gain_rage(15) # 用技能也额外积攒一点怒气 player.use_skill(enemy) else: player.attack(enemy) if enemy.is_alive() and player.is_alive(): time.sleep(1) enemy.attack(player) # 战士受到攻击后积攒怒气 if isinstance(player, Warrior): player.gain_rage(10) round_num += 1 print("\n========== 战斗结束 ==========") if player.is_alive(): print(f"你赢了!获得经验值 50") player.gain_exp(50) else: print("你输了,抬回去治疗吧……")

把主程序组合起来跑一场:

if __name__ == "__main__": # 选择职业 choice = input("选择职业:1=战士,2=法师:") if choice == "2": hero = Mage(name="林小火", hp=90, atk=26, defense=3, mp=80) else: hero = Warrior(name="盾山", hp=120, atk=18, defense=8) slime = Monster(name="史莱姆王", hp=150, atk=15, defense=4) battle(hero, slime)

代码输出大概是这个效果:

遭遇战:盾山 [Lv.1] HP 120/120 攻 18 防 8 VS 史莱姆王 [野怪] HP 150/150 攻 15 防 4 ----- 第 1 回合 ----- 选择操作:1=普通攻击,2=技能:1 盾山 攻击 史莱姆王,造成 14 点伤害 史莱姆王[野怪] 攻击 盾山,造成 7 点伤害 ...

time.sleep(1)把回合节奏拉慢一点,让战斗有点临场的紧张感。别小看这个小细节,代码模拟和游戏心态差别就在这里。

4. 新手最容易踩的四个坑

4.1 可变默认参数的“共享陷阱”

这是Python里经典到不能再经典的坑。很多人写类时会这样:

class Character: def __init__(self, name, inventory=[]): # 千万别这么写 self.name = name self.inventory = inventory

问题在于:默认参数[]只用一份,多个角色实例会共享同一个列表。你给hero.inventory.append("治疗药水"),另一个monster.inventory里也莫名多了一瓶药水。原因很简单,函数定义只执行一次,默认列表在定义那一刻就被定下来了,之后每次调用拿到的都是同一个对象。

正确的做法是:在类内部创建新容器,而不是把可变对象设为默认值:

def __init__(self, name): self.name = name self.inventory = []

或者更明确一点,用None占位再在方法内部初始化:

def __init__(self, name, inventory=None): self.name = name self.inventory = [] if inventory is None else inventory

这个坑面试常问、实际项目经常出问题,值得从第一天就形成肌肉记忆。

4.2 忘写括号、忘写self、忘给属性赋值

三个新手高频小bug,放在一起说:

  • hero.attack是“方法对象”,hero.attack()才是“调用方法”。前者打印出来是一串内存地址,后者才会真正执行攻击逻辑。忘记加括号,程序可能不会报错,因为你只是引用而没有执行,结果就是角色站桩不攻击,找半天bug才发现问题。
  • 方法里忘记写self.,直接写hp -= damage,Python会把它当局部变量处理,函数一结束就被丢弃,角色的血量根本没变化。此时你需要在类的方法里明白一个原则:想操作“谁”的数据,就必须通过self.来访问。漏掉self就像写作文漏了主语,整句话的组织关系就乱了。
  • 在__init__里只给部分属性赋值,其他属性到用的时候才临场self.xxx = 100,这在语法上合法,但一旦在赋值前访问就立刻报AttributeError。更好的习惯是:所有属性都在__init__里初始化,哪怕暂时用不到,也先给一个占位默认值,比如self.status = "正常"。这样对象从被创建的第一刻起就是一个状态完整的对象。

4.3 千万别以为Python有真正的私有属性

很多学过Java或者C++的人刚转Python时会问:类里加个双下划线__hp是不是就私有化了?答案是:Python没有绝对的私有成员,双下划线只是“名称改写”。

具体机制是:一个写成__hp的属性,在外部访问时实际上被Python在内部改写成了_ClassName__hp。外部直接写hero.__hp会报错,但写hero._Character__hp依然能访问到。所以这个名字改写存在的意义是“防止误触”,而不是“防止偷看”。

为了解决“我真的想让外部代码遵守约定,减少误用”,规范的做法是:对不希望外部直接改的内部字段加单个下划线前缀,例如self._hp。下划线是Python社区的一种约定俗成的君子协定,约定是“看起来是内部使用的,外部请勿直接操作”。如果你需要给外部提供修改血量的途径,就明确写一个方法(比如take_damage)让外部通过方法去改,这会把“改数据”这件事变成一个受控过程,方便日后增加校验、日志、事件广播逻辑——这才是面向对象追求的真正目标。

4.4 通过实例修改类属性改错地方

前面提到GAME_NAME定义在类里,这是类属性。访问它用hero.GAME_NAME没问题,但要修改它的时候建议用Character.GAME_NAME = "新名字"。

如果你用hero.GAME_NAME = "新名字"去改,会发生什么?Python把hero这个实例上创建了一个全新的实例属性GAME_NAME,它遮蔽了类属性。其他角色打印GAME_NAME还是老名字,而你自己的角色却显示新名字。数据变得不一致,而且非常不明显。

判断一个属性属于类还是实例,核心看两点:它在哪一级定义,以及它天然应该全类共享还是每个实例独立。经验值是实例属性,因为每个角色的经验是独立的;游戏名、基础经验升级曲线这些是类属性,因为它对所有人一视同仁。搞混这两者的后果就是同一个类下的角色行为不统一,排查起来还很隐蔽。

5. 这套系统还能怎么进化

5.1 组合与聚合:给角色一个背包

继承解决的是“是什么”的纵向关系,组合解决的是“有谁”的横向关系。一个角色拥有背包,背包里有装备和道具,这种关系用继承完全没有办法表达,继承表达的是一种“is-a”的强关联,而组合是一个“has-a”的关系,是整体与部分的关系。

将组合引入项目后,可以这样设计:

class Item: def __init__(self, name, effect_value): self.name = name self.effect_value = effect_value def use(self, target): target.hp = min(target.max_hp, target.hp + self.effect_value) print(f"{target.name} 使用了 {self.name},恢复 {self.effect_value} 点生命") class Backpack: def __init__(self, owner): self.owner = owner self.items = [] def add_item(self, item): self.items.append(item) def use_item(self, index, target=None): item = self.items.pop(index) item.use(target if target else self.owner) # 在Character.__init__里加一行: # self.backpack = Backpack(self)

这样做的最大好处是:背包的增删逻辑被封装在Backpack内部,角色类不需要关心背包怎么运作,它只需要知道“我有背包,包里能放东西,能拿东西用”。这种思路让大系统的模块各自独立演进,角色不知道背包内部如何实现,背包也不知道角色战斗逻辑,两者通过接口协作,互相替换都不受影响。这种解耦思想是工程化开发中最核心的设计目标之一。

5.2 用“接口”统一所有战斗单位

现在回看战斗代码里的isinstance(player, Warrior)判断,你会发现它写得很笨重。如果游戏加一个盗贼职业、一个治疗职业,这种if...elif链会越来越长,战斗函数越来越难维护。

更优雅的做法是让所有角色类统一实现一个技能接口,然后在战斗里无脑调用use_skill。现在基类还没定义use_skill,战士和法师各自定义了,这有个隐患:万一某个子类忘了重写这个方法,战斗循环里调用use_skill会直接抛AttributeError。

更好的做法是在基类给一个合理的默认实现:

class Character: def use_skill(self, target): # 没有特殊技能时,默认使用普通攻击 return self.attack(target)

然后所有子类都去重写这个方法,战斗循环直接调用player.use_skill(enemy)而不关心它到底是什么职业。再往后,如果你想用更严格的方式约束“所有角色必须有use_skill”,可以引入abc模块:

from abc import ABC, abstractmethod class Character(ABC): @abstractmethod def use_skill(self, target): """所有角色必须实现技能逻辑"""

这样一来,所有继承Character的类如果漏掉了use_skill,程序在创建实例时直接报错,错误在编码阶段就被暴露,而不是等到运行时才炸出来。这个机制叫“抽象基类”,相当于给团队协作立了一条规则,逼着所有人按接口办事。等到你的项目需要多人协作、需要别人接手你的角色类定义时,你会庆幸当初在这个接口上花了时间。

5.3 状态效果与事件通知:从小项目走向大系统

这套角色系统虽然已经能跑,但离真正的小游戏还差两件事:状态效果和事件同步。

  • 状态效果:中毒、冰冻、护盾、眩晕、嘲讽,这些都是游戏里非常常见的机制。要给系统加上这些状态,正确思路是给角色加一个status_effects列表,每一项是一个效果对象——它有自己的持续回合数、每回合触发逻辑、结束条件。战斗循环每回合开始前遍历角色身上的效果,该扣血扣血,该限制行动限制行动。这套“效果即对象”的思路,本质上是把业务逻辑从角色类里抽出来,让角色变得无脑而稳定。
  • 事件通知:当怪物被击杀、英雄升级、生命值低于20%,界面需要弹出提示,任务系统需要记录进度。如果你在每个地方都手动写死打印逻辑,最后代码会变成一团浆糊。工程上会用发布-订阅模式,让角色在特定时刻发布事件,界面、任务、统计模块各自去订阅这些事件。这也是观察者模式在游戏里的典型用途。

那是不是说新手项目必须一上来就搞这些重型机制?当然不是。你的第一个角色系统最重要的目标是“跑起来、看得懂、能改”。先把类、继承、方法、属性这些基础概念通过实战吃透,等这套系统写熟了,再逐步把这些工程化武器叠加进去,你会发现每一步升级都是有目标的,而不是为了模式而模式,那种学习反而更省力。

我个人练这种项目最深的体会是:面向对象不是一种语法,而是一种组织代码的思维方式。写游戏角色系统的过程,就是在训练自己把现实世界的复杂系统拆成“对象—行为—协作”的过程。你可以今天写英雄,明天给英雄加宠物,后天给宠物加羁绊系统,每一步都让旧代码在局部扩展,而不是把以前写的东西推倒重来——这才是OOP真正给你带来的长期收益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询