一种易扩展游戏技能系统的实现方案
最近在实现一个卡牌类游戏,因此研究了一下卡牌游戏的核心玩法:卡牌技能,并给出一种比较通用的技能系统设计实现,稍作总结。
参考
- 游戏技能系统开发,腾讯游戏学院的教学视频
- 如何设计一个易扩展的游戏技能系统?,高赞的回答非常棒,本文主要借鉴了其中的思路
- 游戏机制设计3:技能系统的设计思考过程
游戏玩法
之前玩过一个叫做 Stormbound 雷鸣风暴的卡牌战棋游戏,类似于 【炉石传说】和【皇室冲突】结合的玩法
下面是整个游戏的战斗场景,由玩家自己和对手两人组成
整个游戏需要两个玩家依次出牌,放置在 5x4 的棋盘上,可放置的位置由当前棋盘上己方已有的棋子位置决定
每回合开始,棋盘上剩余的棋牌会朝敌方城堡移动,到最后移动到城堡时,则攻击敌方城堡血量,当城堡血量小于 0 时游戏胜利;反之自己的申报血量少于 0 时游戏失败
每张卡牌对应的属性包括
- 能量消耗,每次出牌需要扣除一定的能量,每回合开始能量会重置成最大值+1。
- 血量,当卡牌攻击时,扣除敌方当前卡片的血量,同时当前卡牌也扣除敌方的血量,血量小于 0 时从棋盘上移除
- 类型,包含可移动单位、建筑卡和技能卡
- 初始步数
下面是官网上截取的一部分卡牌信息
卡牌游戏中存在丰富的卡牌,每种卡牌都有各自的特殊作用,比如
- 游戏单位,在出牌时对对手城堡造成 1 点伤害
- 游戏单位,死亡时对周围敌人造成伤害,附加中毒效果
- 游戏单位,每次攻击前,如果对手牌血量少于 3 点,则直接消灭
- 技能,对选择的游戏单位进行治疗
- 建筑,每回合对对手城堡造成 1 点伤害
因此,玩家需要考虑在本回合的能量限制下,应该打出哪些牌,能够获得最优的收益,最终获取游戏胜利。
本文将参考雷鸣风暴的游戏机制,研究如何实现类似的技能系统。
基础类
由于篇幅有限,下面非技能相关的代码就不贴出全部的实现代码了
Card
先来设计一下卡牌的基础属性
enum CardType {
unit, // 单元
skill, // 技能
construct, // 建筑
}
export class Card {
player: Player; // 属于的玩家
chessboard: CardChessboard; // 棋盘
target: Card; // 敌方
name: string;
costEnergy: number;
firstStep: number;
moveStep: number;
hp: number;
x: number;
y: number;
}
Player
Player
是玩家类,主要负责实现能量值、生命值、抽卡等功能
export class Player {
name: string;
dir: number; // 方向
hp: number = 10; // 生命值
maxCardNum: number = 4; // 最大卡片数量
maxEnergy: number = 2; // 最大能量值
// 当前能量
energy: number = 2;
cardList: Card[] = [];
cardFactory: CardFactory; // 负责实现随机抽卡算法
currentCard: Card;
chessboard: CardChessboard;
// 随机补满手牌
drawCard() {}
// 选择卡片
selectCard(card) {}
// 打出一张牌
putCard(card: Card) {}
}
CardChessboard
CardChessboard
是棋盘类,主要负责 5x4 棋盘上放置卡片、计算位置等功能
export class CardChessboard {
currentPlayer: Player;
playerList: Player[] = [];
row: number;
col: number;
cardList: Card[] = [];
putRange: number[];
// 添加玩家
addPlayer(player) {}
// 像指定位置放置卡片
putCard(x, y) {}
// 移除卡片
removeCard(card) {}
// 切换回合
// 切换回合
toggleRound() {}
}
至此,实现了整个游戏依赖的三个基础类,下面展示了游戏开始的伪代码
const chessboard = new CardChessboard();
const p1 = new Player({ name: "p1", cardGroup: [1, 2, 3, 4, 5, 6], dir: -1 });
const p2 = new Player({ name: "p1", cardGroup: [1, 2, 3, 4, 5, 6], dir: -1 });
chessboard.addPlayer(p1);
chessboard.addPlayer(p2);
// 开始回合
chessboard.toggleRound();
// p1选择一张卡牌,然后将其放在x,y的位置上
chessboard.currentPlayer.selectCard(card);
chessboard.putCard(x, y);
// 切换回合
chessboard.toggleRound();
// ... 直至一方城堡的血量为0,游戏结束
技能抽象接口
从前面的描述可以看出,不同的卡牌对应的技能是不一样的,
- 可移动单位的技能可以看做是被动技能,在特定的时机下会生效(打出、死亡、攻击前...)
- 技能卡属于主动技能,由玩家主动触发,目标是卡牌或者对手玩家
- 建筑卡也属于可以看做是被动技能,在每回合开始时生效
- 死亡时对周围敌人造成伤害,附加的中毒效果等 Buff,也可以理解成是被动技能
随着游戏的设计,可能还会有更多类似的技能被添加进来,如装备、铭文等,因此需要对整个技能系统进行抽象
为了方便理解,下面统一将主动技能称为Skill
、被动技能和状态称为Buff
Effect
稍微思考一下,技能的核心其实是对游戏内元素的数值修改,比如修改卡片的生命值,修改玩家的生命值等,因此可以设计一个Effect
接口来承载对应的修改
// 效果接口
export interface Effect {
cast(target: EffectTarget): void;
}
EffectContainer
一个技能可能包含一个或多个数值的修改,可以抽象成EffectContainer
// 技能、装备、buff 等都可以当做是 effect容器
export interface EffectContainer {
// 返回多个effect
getEffects(): Effect[];
}
这里以 Buff 为例展示EffectContainer
中effectList
的使用
export abstract class Buff implements EffectContainer {
// 获取功能列表
abstract getEffects(): Effect[];
// 生效时
work(target: EffectTarget) {
const effectList = this.getEffects();
for (const effect of effectList) {
effect.cast(target);
}
}
}
EffectTarget
游戏元素则使用EffectTarget
接口,包含多个 buff、skill 等,下面以 Buff 为例
export abstract class EffectTarget {
// 拥有的buff列表
buffList: Buff[] = [];
constructor({ buffList = [] }) {
super();
// 配置一些被动buff
this.initBuffList(buffList);
}
// 等待子类实现
abstract initBuffList(buffList): void;
// 添加buff
addBuff(buff: Buff) {
buff.install(this);
this.buffList.push(buff);
}
// 移除buff
removeBuff(buff: Buff) {
this.buffList = this.buffList.filter((b) => b !== buff);
}
}
这样,我们的 Card 就可以继承EffectTarget
,为每张卡片配置对应的被动技能了
export class Card extends EffectTarget {
initBuffList(buffList) {
// 实现抽象接口
}
}
一些 Effect 示例
回到Effect
,现在我们就可以通过实现 Effect 的 cast 接口来实现各种技能效果了,下面展示了一些技能效果的实现
治疗对应卡片
export class RecoverEffect implements Effect {
num: number;
constructor(args) {
this.num = args[0];
}
// Card继承至EffectTarget之后,就可以实现 针对于Card的 Effect了
cast(target: Card) {
target.underRecover(this.num);
}
}
对玩家造成伤害
export class AttackPlayerEffect implements Effect {
damage: number;
constructor(args) {
this.damage = args[0];
}
cast(target: Card) {
target.attackPlayer(this.damage);
}
}
对目标添加中毒 buff
export class PoisoningEffect implements Effect {
damage: number;
duration: number;
constructor(args) {
this.damage = args[0];
this.duration = args[1];
}
cast(target: Card) {
// 对目标添加中毒效果
const buffConfig = createPositionBuffConfig(this.damage, this.duration);
target.addBuff(buff);
}
}
基于事件的被动技能 Buff
前面提到,卡片的被动技能是在不同的时机下触发了,为了解耦,最简单的做法是使用发布订阅,
因此,只要 EffectTarget 在继承一个 EventBus 就可以了
class EventBus {
eventMap: {
[prop: string]: Array<Function>;
} = {};
on(type, handler) {}
emit(type, payload?) {}
}
abstract class EffectTarget extends EventBus {
// ...其他方法
// 添加buff
addBuff(buff: Buff) {
buff.install(this);
this.buffList.push(buff);
}
}
当添加 Buff 时,就需要注册对应的事件,同时还需要考虑 Buff 的有效时间,因此我们对 Buff 基类稍作修改,添加install
接口和 duration 属性
export abstract class Buff implements EffectContainer {
duration: number = Infinity; // 有效时间,被动技能可以认为有效期是无限的
// 获取功能列表
abstract getEffects(): Effect[];
// 添加时
abstract install(target: EffectTarget): void;
// 生效时
work(target: EffectTarget) {
const effectList = this.getEffects();
for (const effect of effectList) {
effect.cast(target);
}
// 处理有效期
this.duration--;
if (this.duration < 0) {
target.removeBuff(this);
}
}
}
然后来实现 CardBuff 类,在install
时注册对应的事件即可,比如“在出牌后对对手城堡造成 1 点伤害” 这个 Buff
export enum CardEventEnum {
afterPut,
}
class AttackPlayerOnPutBuff extends Buff {
install(target: Card) {
target.on(CardEventEnum.afterPut, () => {
this.work(target);
});
}
getEffects() {
return [
new AttackPlayerEffect(1), // 对对手造成1点伤害
];
}
}
这样,当向卡片添加 buff 时,就会通过 install 注册对应的事件处理函数,在事件处理函数内会遍历全部的 effect 执行,更新目标状态
const buff = new AttackPlayerOnPutBuff();
card.addBuff(buff);
宾果,还差最后一步,需要在打出牌时触发事件CardEventEnum.afterPut
,回到CardChessboard
类的实现
export class CardChessboard {
async putCard(x, y) {
// ... 其他操作
card.emit(CardEventEnum.afterPut);
}
}
类似地,在代码流程相关的地方 emit 对应事件,在 Buff 的 install 中注册该 buff 依赖的事件和相关的 effect 即可。各个模块互不依赖,向CardChessboard
甚至感觉不到卡片 Buff 的存在。
主动技能 Buff
主动技能甚至比被动技能 Buff 更加简单,只需要提供一个主动调用的接口即可
export abstract class Skill implements EffectContainer {
abstract getEffects() {}
spellTo(target: EffectTarget) {
const effectList = this.effectList;
for (const effect of effectList) {
effect.cast(target);
}
}
}
export abstract class EffectTarget extends EventBus {
// 使用技能
useSkill(skill: Skill, target: EffectTarget) {
skill.spellTo(target);
}
}
比如一个对目标造成 10 点伤害、同时添加中毒 buff 的施毒术
class PoisoningSkill extends Skill {
getEffects() {
return [
new DamageEffect(10), // 立即造成10点伤害
new PoisoningEffect([3, 2]), // 每次造成3点伤害,持续2回合
];
}
}
数据驱动的技能配置
在上面的代码中,我们实现了AttackPlayerOnPutBuff
、PoisoningSkill
等技能,
在实际开发中,可能会有大量的技能需要实现,比如上面的PoisoningSkill
,如果需要动态控制首次伤害和持续伤害及持续时间,就需要进行一下改造
class PoisoningSkill extends Skill {
firstDamage: number;
damage: number;
duration: number;
constructor(args) {
this.firstDamage = args[0];
this.damage = args[1];
this.duration = args[2];
}
getEffects() {
return [
new DamageEffect(this.firstDamage),
new PoisoningEffect([this.damage, this.duration]),
];
}
}
const skill1 = new PoisoningSkill([10, 3, 2]);
const skill2 = new PoisoningSkill([20, 10, 5]);
这个代码看起来很熟悉,在 Effect 那里,我们也需要实现很多的 Effect,用来改变 EffectTarget 的状态。
因此,这里继续重复实现大量的技能类貌似并不是一个很明智的做法,我们需要提前在编写很多代码,然后暴露出有哪些技能可以使用。
反之,如果将技能进一步抽象,在业务扩展中,就可以在不修改代码的情况下,通过配置(一般是配表或者修改数据库)实现更多的技能效果。
实际上实现起来也比较简单,首先约定一下配置项,假设 Buff 的配置是这样的
export interface BuffConfig {
name: string;
desc: string;
event: string;
effects: { name: string; args: any[] }[];
}
const AttackPlayerOnPutBuff = {
name: "死亡伤害",
desc: "死亡时对对手造成1点伤害",
event: "onDie",
effects: [
{
// 将Effect调整成字符串形式,方便序列化
name: "AttackPlayerEffect",
args: [1],
},
],
};
然后实现一个 CardBuff 类,在构造时解析配置就可以了
export class CardBuff extends Buff {
event: string;
desc: string;
effectList: Effect[];
constructor(config: BuffConfig) {
const { name, desc, effects, event, duration } = config;
super(name);
this.event = event;
this.desc = desc;
this.effectList = effects.map(({ name, args }) => {
return initEffectWithName(name, args);
});
}
install(target: Card) {
target.on(CardEventEnum[this.event], () => {
this.work(target);
});
}
getEffects() {
return this.effectList;
}
}
这样,只需要传入对应的 BuffConfig,就可以得到相关的 Buff 技能,而无需编写一个具体的子类了
示例:被动召唤侍从
下面展示了一个比较复杂的被动技能:出牌时在后方空地召唤 1 个 1 点 HP 的侍从
function createBuffConfig(name, desc, event, duration, effects): BuffConfig {
return {
name,
desc,
event,
duration,
effects,
};
}
// 与BuffConfig类似,这里使用config来构建不同的卡牌
function createCardConfig(
id,
name,
costEnergy,
firstStep,
hp,
buffList,
moveStep = 1
) {
return {
id,
name,
costEnergy,
firstStep,
moveStep,
hp,
buffList,
};
}
const spawnBuff = createBuffConfig(
"召唤侍从",
"出牌时在后方空地召唤1个1点HP的侍从",
"afterPut",
1,
[
{
name: "SpawnEffect",
args: ["后方", createCardConfig("-1", "新兵", 0, 0, 1, [], 1)],
},
]
);
const knightCard = createCardConfig(2, "侍从", 3, 0, 3, [spawnBuff], 1);
这样,当打出knightCard
这张卡片时,就可以向身后的空地召唤一个1点生命值的侍从,当然,这里还需要实现SpawnEffect
export class SpawnEffect implements Effect {
pos: string
spawnChessConfig: any
constructor(args) {
this.pos = args[0]
this.spawnChessConfig = args[1]
}
cast(target: Card) {
const card = new Card(this.spawnChessConfig)
card.costEnergy = 0
const {player, chessboard} = target
const pos = {
x: target.x - player.dir,
y: target.y
}
player.selectCard(card)
chessboard.putCard(pos.x, pos.y)
}
}
基于这种数据形式的约定,开发人员只需要提供各种各样的Effect,策划就可以配置不同的技能效果,而无需修改代码,当然肯定有当前Effect不满足的情况,这个时候横向扩展就可以了。
小结
原本是打算做一款战棋游戏,核心功能实现完成之后发现被剧情脚本卡住了。
正在纠结的时候,发现基于当时的版本可以很轻松的做一个一个简易版的Stormbound,于是有了本文
目前只象征性的移植了几张卡牌,理论上是可以将大部分卡牌的技能都通过数据配置出来,目前看起来这个简易的技能系统框架扩展性还是可以的
在真实的游戏开发中,技能效果一般包括技能展示(动画)和技能逻辑(数据)。本文侧重于技能逻辑的设计,对技能展示关注比较少,需要进一步学习。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。