一种易扩展游戏技能系统的实现方案

最近在实现一个卡牌类游戏,因此研究了一下卡牌游戏的核心玩法:卡牌技能,并给出一种比较通用的技能系统设计实现,稍作总结。

<!--more-->

参考

1. 游戏玩法

之前玩过一个叫做 Stormbound 雷鸣风暴的卡牌战棋游戏,类似于 【炉石传说】和【皇室冲突】结合的玩法

下面是整个游戏的战斗场景,由玩家自己和对手两人组成

整个游戏需要两个玩家依次出牌,放置在 5x4 的棋盘上,可放置的位置由当前棋盘上己方已有的棋子位置决定

每回合开始,棋盘上剩余的棋牌会朝敌方城堡移动,到最后移动到城堡时,则攻击敌方城堡血量,当城堡血量小于 0 时游戏胜利;反之自己的申报血量少于 0 时游戏失败

每张卡牌对应的属性包括

  • 能量消耗,每次出牌需要扣除一定的能量,每回合开始能量会重置成最大值+1。
  • 血量,当卡牌攻击时,扣除敌方当前卡片的血量,同时当前卡牌也扣除敌方的血量,血量小于 0 时从棋盘上移除
  • 类型,包含可移动单位、建筑卡和技能卡
  • 初始步数

下面是官网上截取的一部分卡牌信息

卡牌游戏中存在丰富的卡牌,每种卡牌都有各自的特殊作用,比如

  • 游戏单位,在出牌时对对手城堡造成 1 点伤害
  • 游戏单位,死亡时对周围敌人造成伤害,附加中毒效果
  • 游戏单位,每次攻击前,如果对手牌血量少于 3 点,则直接消灭
  • 技能,对选择的游戏单位进行治疗
  • 建筑,每回合对对手城堡造成 1 点伤害

因此,玩家需要考虑在本回合的能量限制下,应该打出哪些牌,能够获得最优的收益,最终获取游戏胜利。

本文将参考雷鸣风暴的游戏机制,研究如何实现类似的技能系统。

2. 基础类

由于篇幅有限,下面非技能相关的代码就不贴出全部的实现代码了

2.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;
}

2.2. 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) {}
}

2.3. 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,游戏结束

3. 技能抽象接口

从前面的描述可以看出,不同的卡牌对应的技能是不一样的,

  • 可移动单位的技能可以看做是被动技能,在特定的时机下会生效(打出、死亡、攻击前...)
  • 技能卡属于主动技能,由玩家主动触发,目标是卡牌或者对手玩家
  • 建筑卡也属于可以看做是被动技能,在每回合开始时生效
  • 死亡时对周围敌人造成伤害,附加的中毒效果等 Buff,也可以理解成是被动技能

随着游戏的设计,可能还会有更多类似的技能被添加进来,如装备、铭文等,因此需要对整个技能系统进行抽象

为了方便理解,下面统一将主动技能称为Skill、被动技能和状态称为Buff

3.1. Effect

稍微思考一下,技能的核心其实是对游戏内元素的数值修改,比如修改卡片的生命值,修改玩家的生命值等,因此可以设计一个Effect接口来承载对应的修改

// 效果接口
export interface Effect {
    cast(target: EffectTarget): void;
}

3.2. EffectContainer

一个技能可能包含一个或多个数值的修改,可以抽象成EffectContainer

// 技能、装备、buff 等都可以当做是 effect容器
export interface EffectContainer {
    // 返回多个effect
    getEffects(): Effect[];
}

这里以 Buff 为例展示EffectContainereffectList的使用

export abstract class Buff implements EffectContainer {
    // 获取功能列表
    abstract getEffects(): Effect[];

    // 生效时
    work(target: EffectTarget) {
        const effectList = this.getEffects();
        for (const effect of effectList) {
            effect.cast(target);
        }
    }
}

3.3. 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) {
        // 实现抽象接口
    }
}

3.4. 一些 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);
    }
}

4. 基于事件的被动技能 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 的存在。

5. 主动技能 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回合
        ];
    }
}

6. 数据驱动的技能配置

在上面的代码中,我们实现了AttackPlayerOnPutBuffPoisoningSkill等技能,

在实际开发中,可能会有大量的技能需要实现,比如上面的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 技能,而无需编写一个具体的子类了

6.1. 示例:被动召唤侍从

下面展示了一个比较复杂的被动技能:出牌时在后方空地召唤 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不满足的情况,这个时候横向扩展就可以了。

7. 小结

原本是打算做一款战棋游戏,核心功能实现完成之后发现被剧情脚本卡住了。

正在纠结的时候,发现基于当时的版本可以很轻松的做一个一个简易版的Stormbound,于是有了本文

目前只象征性的移植了几张卡牌,理论上是可以将大部分卡牌的技能都通过数据配置出来,目前看起来这个简易的技能系统框架扩展性还是可以的

在真实的游戏开发中,技能效果一般包括技能展示(动画)和技能逻辑(数据)。本文侧重于技能逻辑的设计,对技能展示关注比较少,需要进一步学习。