论卡牌类战斗的实现

我于杀戮之中盛放,一如黎明中的花朵 --烬

卡牌类游戏战斗,这种类型游戏战斗如下图所示:典型的代表有《放置奇兵》,《少年三国志》等,市面上很多这种类型游戏。

放置奇兵战斗.jpg

以下将以《放置奇兵》为例,拟构出其核心战斗系统(在下并非《放置奇兵》公司的,纯属兴趣讨论)

经观察,《放置奇兵》的完整战斗流程为:
一、一入战斗,便有敌我双方两个阵营,每个阵营最多6个单位;
二、每个战斗单位带有一个普攻(能量条未满时施放)、一个主动技能(能量条满时施放)、可能有一个替换技能(激活后替换普攻),其余的都为被动技能,技能如下图所示,从下图可以看出,《放置奇兵》最复杂的技能有如下特点:除了基本的攻击伤害外,还会附带额外伤害,如“对对战士类英雄额外造成30%攻击伤害”(下图没有,其余的技能有);还会施加buff,如“每回合造成102%攻击伤害,持续4回合”,即最复杂的技能 = 基本伤害 + 额外效果 + buff效果。

幻影.png

三、出手时,按攻击速度逐个出手,每回合一个单位只能释放一个攻击类型技能(仅在普攻、主动、替换技能里选),当施放这个技能时,先选择敌方攻击目标,然后分别计算基础伤害(和额外伤害和buff伤害,如有额外效果和buff效果);但这还没结束,因为这个技能的释放,还可能导致触发自己的被动,如“如攻击被敌方闪避,则回复自己30%已损血量”,同样的,这个攻击技能可能会触发被击者的被动,如“生命低于50%时,护甲增加30%”,还有可能触发其他单位的被动,如“友方受到攻击时,为其回复10%生命上限血量”,当所有符合触发条件的单位被动都触发后,这才算一个完整的攻击行为(注意,被动不能触发递归被动,这是游戏规则)。当场上所有能施放技能的单位都都完成自己的完整攻击行为时,一回合便算结束。
四、当其中一方死亡,或战斗回合超规定最大回合数时,整场战斗结束。

关于技能和buff的区别,即什么样的战斗效果做成技能,什么样的战斗效果做成buff?这其实很好区分,如果是即时的战斗效果则应属于技能,如果有时长或多个回合生效的战斗效果则应属于buff,比如一个技能打人掉血,因为这是立刻性的效果,则它属于技能效果,如果一个技能打人持续3秒钟(回合)掉血,那它属于buff类效果。

在这场战斗中,按序收集每回合的每个单位的完整攻击行为后,再加上战斗双方单位的初始血量、能量条,单位外观配置等信息,封装成战报,发送给前端播放,这样一场精彩的战斗流效果便呈现在我们眼前,由上可见,这种类型游戏的战斗都是一入场便已算好整个战斗流程,前端只是按照战报播放打斗特效而已。

通常,做战斗时,最重要的一点是,一定要找策划对清整个战斗流程,从入场到技能释放规则,被动触发机制,buff结算规则,到整场战斗结束,尽量别留需求盲点,因为可能导致功能重构代价昂贵,通常来说,在了解整个战斗流程后,了解的所有技能效果类型越全面,越有利于做出稳健易扩展的战斗系统。根据我的经验,很有可能有那么一两个奇葩技能效果要打破常规特殊处理,如果不了解所有的技能效果,做出的战斗系统往往耦合度高,扩展性差,牵一发而动全身。

通过上面的分析,战斗核心代码可如下依次写出,先创建基本战斗单位类,战斗单位数据的初始化由玩家相应的英雄数据初始化完成,见下BattleService.java可推知Fighter如何创建的,所需数据如下:
战斗单位类 Fighter.java

public class Fighter implements Serializable {
    public long id;// 唯一标示
    public int templateId;// 单位配置模板ID,用于显示单位外观等数据
    public int grid;// 站位格子
    public int level;// 英雄等级-伤害计算基本伤害用到
    public RaceType raceType;// 英雄种族,枚举:幽暗、堡垒、森林、深渊、暗影、光明-前端显示用
    public Carrer carrer;// 英雄职业,枚举:战士、法师、刺客、游侠、牧师-伤害计算中职业伤害和战斗目标选择中用到
    
    // 属性集合,如攻击速度,最大血量,当前血量等,实际战斗中里面的属性会被改变的,因此需要FighterData记住初始属性
    public HashMap<Integer, Integer> properties = new HashMap<>(); 
    // 属性备份数据,即所有战斗单位初始的数据,战斗时Fighter中的属性如血量会变化的,所以要有个初始属性的备份数据
    // 供前端显示战前血量,能量条等,即作战前入场显示用。
    public HashMap<Integer, Integer> bak_properties = new HashMap<>(); 

    public FightSkill commSkill; //普攻
    public FightSkill activeSkill; //主动技能
    public ArrayList<FightSkill> psvSkillList = new ArrayList<>();// 被动技能列表
    
    private Camp camp;//fighter所在阵营
    
    // 单位所中的buff   BuffTemplate type ->  buff list
    public HashMap<Integer, ArrayList<Buff>> buffs = new HashMap<>();
    // 技能使用限制,中了某些buff,在某些回合不能使用技能,或者不能使用某些类型的技能
    // round -> Set<BuffTemplate type> -注:只存在于特殊类如:沉默、冰冻、石化、眩晕buff效果中
    public HashMap<Integer, HashSet<Integer>> skillLimits = new HashMap<>();
}

当把阵型里英雄列表数据转化为战斗单位列表后,便可以初始化双方阵营了,因此再写阵营类
阵营类 Camp.java,
首先需要知道阵营属于攻击方还是被攻击方,做最终的判断哪方输赢用;还需要知道该阵营的战斗单位列表,这个列表由玩家阵型里的英雄列表初始化而来,里面每个战斗单位存储他的模型配置等信息,及它的技能和属性信息,供战斗和计算伤害用,记得备份每个单位的初始属性,以供战斗入场显示用,每个阵营的初始化也可从BattleService.java推知。

public class Camp implements Serializable {
    //阵营分属  枚举:攻击方 或 被攻击方
    public CampType CampType; 
    
    //阵营所有战斗单位列表
    public ArrayList<Fighter> fighters; 
}

当阵营及战斗单位组装好后,即可开始战斗了,战斗接口类为
BattleService.java

public class BattleService{
        public BattleStream pvp(BattleType battleType, List<Hero> challangerHeros, List<Hero> beChallangerHeros){
            // 初始化战斗对象属性及技能
            ArrayList<Fighter> challangerFighters = initHeroFighterData(challangerHeros);
            ArrayList<Fighter> beChallangerFighters = initHeroFighterData(beChallangerHeros);
            // 初始化阵营
            Camp camp = new Camp(CampType.Attack, challangerFighters);
            Camp beCamp = new Camp(CampType.BeAttack, beChallangerFighters);

            //战报
            BattleStream btStream = BattleUtil.doFight(fightType.maxRound(), camp, beCamp);
            return btStream;
        }
}

战斗工具类
BattleUtil.java

public class BattleUtil {
    // 战斗核心逻辑
    public static BattleStream doFight(int maxRound, Camp camp, Camp beCamp) {
      try {
        BattleStream btStream = doFight(maxRound, null, null, camp, beCamp);
        return btStream;
      } catch (Exception e) {
        logger.error("战斗报错");
        return null;
      }
    }
    
    //战斗核心逻辑
    public static BattleStream doFight(int maxRound, BattleStream btStream, Round round, Camp camp, Camp beCamp) {
       // 创建战报
        if (btStream == null) {
            btStream= new BattleStream(maxRound, camp, beCamp);
        }
        // 回合数
        if (round == null) {
            round = new Round();
        }
        // 超过回合数上限
        if (maxRound > 0 && round.getIndex() > maxRound) {
            return btStream;
        }
        btStream.addRound(round);

        // 战斗流程
        if (fighting(btStream, round, camp, beCamp)) {
            return btStream;
        }
        
        // 大回合结束结算buff
        if (buffing(btStream, round, camp, beCamp)) {
            return btStream;
        }
        
        return fight(maxRound, btStream, new Round(round.getIndex() + 1), camp, beCamp);
    } 
    
    //战斗流程
    private static boolean fighting(BattleStream btStream, Round round, Camp camp, Camp beCamp) {
        //所有战斗单位是双方合在一起按攻击速度排序逐个施放技能的
        ArrayList<Fighter> allFighters = mergeAllFighters(camp, beCamp);
        for (Fighter fighter : allFighters) {
            if (fighter == null || fighter.getCurHp() <= 0)
                continue;
            
            // 执行完整攻击动作
            doAction(fighter, round, camp, beCamp);
            // 双方只要有一方全部死亡即结束战斗
            if (camp.isAllDie() || beCamp.isAllDie()) {
                return true;
            }
        }
        return false;
    }
    
    //执行完整攻击动作
    public static void doAction(Fighter fighter, Round round, Camp camp, Camp beCamp) {
        //获取当前可用技能
        FightSkill fightSkill = fighter.getFighterSkill(round.getIndex());
        if(fightSkill == null)
            return;
        
        //选取技能作用目标对象
        SkillTemplate template = SkillTemplate.getTemplate(fightSkill.id);
        ArrayList<Fighter> targets = getSkillTargets(template, fighter, camp, beCamp);
        if (CollectionUtils.isEmpty(targets)){
            logger.error("fighter:"+fighter+",at round:"+round.getIndex()+" fire skill without targets, skillId:" + fightSkill.id);
            return;
        }
                
        // 一个攻击动作,添加到回合
        Action action = Action.action(ActionFlag.SKILL, fighter.id, fightSkill.id);
        round.addAction(action);
        
        //执行技能效果
        SkillEffectType skillEffect = SkillEffectType.valueOf(template.getEffect());
        ArrayList<Result> results = skillEffect.getSkillEffect().execute(fightSkill, fighter, targets, action, round);
        if(CollectionUtils.isEmpty(results)){
            throw new RuntimeException("施放伤害主动技能获取的结果为空,fighter:"+fighter+", fightSkill:"+fightSkill);
        }
        action.getResults().addAll(results);
        
        //增加能量条
        addFighterEnergy(fighter, template, action);
        addTargetEnergy(fighter, targets, action, template.getHitenergy());
        
        //增加buff
        addBuff(fighter, fightSkill, results, targets, action, round, camp, beCamp);
        //技能额外效果-放在增加buff后,因为有些技能的额外效果是对特殊状态的目标造成额外伤害,而特殊状态通常是因buff添加的
        doExtraEffect(fighter, fightSkill, results, targets, action, round, camp, beCamp);
        
        //触发被动
        triggerPassiveSkill(targets, results, fighter, fightSkill, action, round, camp, beCamp);
    }
}

在上图《放置奇兵》幻影 的技能可以看出,一个技能最复杂的情况是除了基础攻击伤害,还会有额外伤害,此外还会有buff,因此在上面的doAction方法中,先执行了技能的基础攻击伤害效果,后面又执行了addBuff方法,最后执行了额外伤害方法doExtraEffect,因为一个完整的攻击行为还包括触发敌我双方甚至场上其他单位的被动技能,因此最后又执行了触发被动方法triggerPassiveSkill,需依次判断是否触发攻击者被动,是否触发被击者被动,是否触发其他单位被动。以上便是整个战斗流程的重点代码实现,中间还省略了其他细节。如一个技能不止有伤害效果,可能还有回血效果,还有复活效果,还有清除控制类buff效果,还有引爆中毒等buff效果,这些技能效果及其造成的伤害结果,都是通过

SkillEffectType skillEffect = SkillEffectType.valueOf(template.getEffect());
ArrayList<Result> results = skillEffect.getSkillEffect().execute(fightSkill, fighter, targets, action, round);

来实现的,被动的技能效果也是如此。
最后是整个战报的封装,先看战报类
BattleStream.java,
通过两个阵营,前端可以知道各个阵营的初始化数据及单位外观信息;rounds存储所有回合战斗信息。

public class BattleStream implements Serializable {
    private Camp camp;// 攻击阵营
    private Camp beCamp;// 被攻击阵营
    private ArrayList<Round> rounds = new ArrayList<>();// 回合
}

再看回合类
Round.java

public class Round implements Serializable {
    private int index;//第几个回合
    private ArrayList<Action> actions; // 所有战斗单位的完整攻击行为集合
}

攻击行为类
Action.java,
前端在战斗入场显示各单位外观及初始血量等信息后,接下来即需播放战斗动画,一个技能打出后,需知道是哪个单位释放的;具体打出什么样的技能特效,则需知道该技能的特效配置id;这个技能打中了哪些单位,飘了多少血,则放在results里存储;这个技能触发了哪些单位的被动技能,则放在subAction里。

public class Action implements Serializable {
    public ActionType actionType; //枚举:是父action,还是子action
    private ActionFlag flag; //枚举:此次行为是释放技能,还是增加buff、删除buff、buff结算、额外效果等
    private long atter;// 攻击者
    private int skillTemplateId;// 攻击者使用的技能模板ID
    private ArrayList<Result> results = new ArrayList<>();// 结果列表

    private Action parent = null;//父action
    private ArrayList<Action> subActions = new ArrayList<>();// 当前动作包含的子动作,比如使用技能触发了被动技能,buff等,最多只有一层

        /** 父动作 */
    public static Action action(ActionFlag flag, long fighterId, int templateId) {
        Action action = new Action(ActionType.ACTION);
        action.setFlag(flag);
        action.setAtter(fighterId);
        action.setSkillTemplateId(templateId);
        return action;
    }
    
    /** 子动作 */
    public static Action subAction(Action parent, ActionFlag flag, long fighterId, int templateId) {
        Action action = new Action(ActionType.SUB_ACTION);
        action.setFlag(flag);
        action.setAtter(fighterId);
        action.setSkillTemplateId(templateId);
        action.setParent(parent);
        
        parent.addSubAction(action);
        
        if(parent.actionType == ActionType.SUB_ACTION){
            logger.error("Action parent is subAction too, flag:"+parent.getFlag()+",atter:"+parent.getAtter()+",skillTemplateId:"+parent.getSkillTemplateId()
            +",subAction flag:"+flag+",atter:"+fighterId+",skilltempId:"+templateId);
        }
        return action;
    }
}

最后是结果类
Result.java

public class Result implements Serializable {
    public long hiter;// 伤害人
    public long beHiter;// 被伤害人
    public ResultType type;// 伤害类型,枚举:普通伤害、暴击、格挡、完美格挡、增加能量、增加血量上限等
    public long value;// 伤害值,通过与ResultType搭配,及该值的正负,可以表示扣血和回血,增加能量,减能量等信息
}

再看上面的doAction方法,当一个单位释放技能时,由fighter.getFighterSkill(round.getIndex())获取单位当前能释放的技能,如果能量条满,则要释放大招,如果有替换被动,则要释放替换技能,如果都不满足,才释放普攻,但是,能释放时,还需判断单位在该回合是否有限制技能释放的buff,在Fighter.java的skillLimits

// round -> Set<BuffTemplate type> -注:只存在于特殊类如:沉默、冰冻、石化、眩晕buff效果中
public HashMap<Integer, HashSet<Integer>> skillLimits = new HashMap<>();

中,以当前回合为key值,取出当前回合是否有限制类buff,有则不能释放。如可释放大招时,如果有沉默buff,则不能释放大招,需改为释放普攻,如中了冰冻、石化、眩晕等buff,则什么技能都不能释放,只能让下一个单位释放技能。如果能释放,则以该攻击技能为父节点,开始构造一个技能完整行为,即Action,此后,由此技能触发的buff,额外效果,被动技能,都算为该Action的子Action,一个技能造成的伤害,便封装为Result,放入Action中,一个完整行为再加到战报的回合数中。这样,一个完整的卡牌类战斗就已实现十之八九了。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容