从零开始实现放置游戏(十五)——实现战斗挂机(6)在线打怪练级

简介:  本章初步实现游戏的核心功能——战斗逻辑。  战斗系统牵涉的范围非常广,比如前期人物的属性、怪物的配置等,都是在为战斗做铺垫。  战斗中,人物可以施放魔法、技能,需要技能系统支持。  战斗胜利后,进行经验、掉落结算。又需要背包、装备系统支持。装备系统又需要随机词缀附魔系统。  可以说是本游戏最硬核的系统。  因为目前技能、背包、装备系统都还没有实现。我们先初步设计实现一个简易战斗逻辑。  战斗动作仅包括普通攻击,有可能产生未命中、闪避和暴击。

 本章初步实现游戏的核心功能——战斗逻辑。


  战斗系统牵涉的范围非常广,比如前期人物的属性、怪物的配置等,都是在为战斗做铺垫。


  战斗中,人物可以施放魔法、技能,需要技能系统支持。


  战斗胜利后,进行经验、掉落结算。又需要背包、装备系统支持。装备系统又需要随机词缀附魔系统。


  可以说是本游戏最硬核的系统。


  因为目前技能、背包、装备系统都还没有实现。我们先初步设计实现一个简易战斗逻辑。


  战斗动作仅包括普通攻击,有可能产生未命中、闪避和暴击。


  整个战斗逻辑的流程大致如下图所示:


502227-20200522122551007-1800696048.jpg


一、战斗消息设计


  参照其他消息,战斗动作需要发送请求并接收返回消息,我们先定义两个消息代码 :


    CBattleMob = "30003001"

    SBattleMob = "60003001"

 

  这里我们先仅考虑在线打怪,发送战斗请求,我们仅需要知道怪物id即可,战斗时从数据库读取怪物属性。


  新建客户端消息类如下:


@Data
public final class CBattleMobMessage extends ClientMessage {
    private String mobId;
}


 服务端需要返回战斗的最终结果信息,以及每个回合每个角色的战斗动作记录作给客户端,一遍客户端播放。


新建服务端的消息类如下:


@Data
public class SBattleMobMessage extends ServerMessage {
    private BattleMobResult battleMobResult;
}


@Data
public class BattleRound implements Serializable {
    // 当前回合数
    private Integer number;
    // 回合内战斗记录
    private List<String> messages;
    // 是否战斗结束
    private Boolean end;
    public BattleRound() {
        this.messages = new ArrayList<>();
        this.end = false;
    }
    public BattleRound(Integer roundNum) {
        this();
        this.number = roundNum;
    }
    public void putMessage(String message) {
        this.messages.add(message);
    }
}


这里 BattleMobResult 和 BattleRound 两个类,是返回给页面使用的视图模型,新建时放在game.hub.message.vo.battle包中。


二、战斗单位建模


  在战斗开始时,我们把参战单位那一时刻的属性取出来存一份副本,此后,均以此副本为准进行计算。


  怪物和玩家包的类含的属性差别较大,为了方便统一计算,我们抽象一个BattleUnit类,存储一些通用属性,比如等级,血量。


  其中还定义了一些抽象方法,比如获取攻击强度getAP(),和获取护甲等级getAC()。玩家和怪物需要分别实现这两个抽象方法。


  玩家,战斗属性(二级属性)是由力量、敏捷、耐力、智力这些一级属性进一步计算得出。比如,战士的攻击强度=等级*3+力量*2-20。速度=敏捷。护甲等级=耐力*2。命中率=0.95。闪避和暴击=敏捷*0.0005。


  怪物,只是用来练级的,则没那么麻烦,录入数据时就只有伤害和护甲两项属性。攻击强度直接取伤害值即可。速度直接取0。命中率默认0.95。闪避和暴击率默认0.05。


  这里虚类BattleUnit中又有一个巧妙的实方法getDR(),获取伤害减免。将其写在虚基类中,不管是玩家还是怪物实例,都可以根据自身的AC,计算出相应的DR。


  这里DR的计算公式: 伤害减免 = 护甲等级 / (护甲等级 + 85*玩家(怪物等级 + 400)


/**
     * 获取伤害减免Damage Reduce
     *
     * @return
     */
    public Double getDR() {
        Integer ac = this.getAC();
        return ac / (ac + 85 * this.level + 400.0);
    }


 3个类的UML图如下,具体实现可以下载源代码查看。


502227-20200522131639909-47237051.png


三、战斗机制


  模型建完,就剩战斗逻辑了。其中,一个核心的问题就是战斗动作的判定。即发起一次普通攻击后,到底是被闪避了,还是被格挡了,还是产生了暴击,或者仅仅是命中。其中,每一项可能的结果需要单独ROLL点吗?这里不同的游戏会有不同的实现。我们参考使用魔兽的判定方法,圆桌理论,即只ROLL一次点,这样逻辑更加容易处理。


  圆桌理论


  "一个圆桌的面积是固定的,如果几件物品已经占据了圆桌的所有面积时,其它的物品将无法再被摆上圆桌"


  这个理论在战斗逻辑中,即把可能产生的结果按优先级摆放到桌上,比如以下这种情形(其中的概率会因属性、装备等的不同而变化,这里只是举例):


  • 未命中(5%)
  • 躲闪(5%)
  • 招架(20%)
  • 格挡(20%)
  • 暴击(5%)
  • 普通攻击


  只ROLL一次点,如果ROLL到3,则玩家未命中怪物;如果ROLL到49,则玩家的攻击被怪物格挡;超过55的部分,都是普通攻击。


  假如这里玩家换上暴击装,暴击率达到60%。则圆桌上全部结果的概率已超出100%,ROLL到50-100全部判定为暴击,普通攻击被踢下圆桌,永远不会发生。

 

  在本此实现中,我们仅考虑物理未命中、闪避和暴击。暂不考虑二次ROLL点(攻击产生暴击,但被闪避或格挡了),以及法术技能的ROLL点。


四、战斗逻辑实现


  有了以上基础,我们就可以通过代码实现完整的战斗逻辑了。


  这里,虽然目前仅包含在线打怪,但以后可能会包含组队战斗,副本战斗,PVP等逻辑。我们把战斗逻辑放到单独的包里,com.idlewow.game.logic.battle,在这里新建战斗逻辑的核心类BattleCore,具体实现代码如下:


 BattleCore.java


 如上图代码,首先我们初始化一份各参战单位的属性副本,并添加到创建的3个列表中,其中atkList, defList用来检测是否其中一方全部阵亡,battleList则用来对参战单位按速度排序,确定出手顺序。


  这里使用了归并排序来对集合进行排序,具体算法在BattleUtil类中。考虑到这里对集合的添加、修改、删除操作较多,使用LinkedList链表来保存参战集合。(实际上数据较少,使用ArrayList可能也没什么差别)。


  这里仅仅在回合开始前确定了一次出手顺序,因为目前没有引入技能,假如引入技能后,比如猎人施放豹群守护,我方全体速度+50,那么需要对出手列表进行重新排序。


  进入循环后,随机选定攻击目标 --> 确定出手动作 --> 存活检测 --> 战斗结束检测, 这里注释和代码比较清楚,就不一一讲解了。


  这里攻击动作和结果确定后,会在返回信息中添加对此的描述,后面考虑如果后端传输这些内容太多不够优雅,也可以定义一套规则,只传输关键数据,战斗记录由前端生成。不过目前先不考虑。


  战斗结束后,如果玩家胜利,需要结算经验值。经验值相关的计算在ExpUtil中,文后会附上经验值计算公式。


五、播放战斗记录


  战斗计算完成后,后端会返回战斗信息给前端,前端只负责播放即可。


  播放记录的方法代码如下:


 // 加载在线打怪战况
    loadBattleMobResult: async function (data) {
        let that = this;
        $('.msg-battle').html('');
        let rounds = data.battleMobResult.roundList;
        if (data.battleMobResult.totalRound > 0) {
            for (let i = 0; i < rounds.length; i++) {
                let round = rounds[i];
                let content = "<p>【第" + round.number + "回合】</p>";
                for (let j = 0; j < round.messages.length; j++) {
                    content += "<p>" + round.messages[j] + "</p>";
                }
                content += "<hr />";
                $('.msg-battle').append(content);
                await this.sleep(1500);
            }
            $('.msg-battle').append("<p><strong>" + data.battleMobResult.resultMessage + "</strong></p>");
            if (data.battleMobResult.playerWin) {
                that.sendLoadCharacter();
            }
            if (that.isBattle) {
                that.battleInterval = setTimeout(function () {
                    that.sendBattleMob(that.battleMobId);
                }, 5000);
            }
            // await this.sleep(5000).then(function () {
            //     that.sendBattleMob(data.battleMobResult.mobId);
            // });
        }
    },


 上面的代码中,最后3行被注释掉的代码,即5秒钟后,再次攻击此怪。如果只考虑打怪和用setTimeout方法实现,其实没有差别。


  但在业务上,考虑玩家可能需要点击停止打怪,那么用setTimeout来执行循环,可以用clearInterval来终止函数执行。


/* 在线打怪 */
function battleMob(mobId) {
    let diff = new Date().getTime() - wowClient.battleMobTime;
    if (diff < TimeLag.BattleMob * 1000) {
        let waitSeconds = parseInt(TimeLag.BattleMob - diff / 1000);
        alert('请勿频繁点击!' + waitSeconds + '秒后可再操作!');
        return;
    }
    if (mobId === wowClient.battleMobId) {
        alert("已经在战斗中!请勿重复点击!");
        return;
    }
    wowClient.battleMobId = mobId;
    wowClient.battleMobTime = new Date().getTime();
    if (!wowClient.isBattle) {
        wowClient.isBattle = true;
        wowClient.sendBattleMob(mobId);
    }
}


 上图中是点击‘打怪’按钮的方法,这里我直接把代码贴出来,显得比较清晰简单。


实际上做的时候,经过反复改动和考虑。代码中解决的一些问题,可能三言两语也不太好体现出来,需要自己实际编写代码才能体会。


  比如考虑这个场景,玩家A,在线攻击怪物a , 开启的对a的战斗循环。A升级后,想攻击更高级的怪物b。这时比较合理的操作方式就是玩家直接点击b的战斗按钮。


  那么我们可能要考虑几个问题:


    怪物a的战斗循环需不需要停止,怎么停止;如果要停止战斗,但此时正在播放战斗记录,还没进入5秒的循环,停止循环函数不会生效,该怎么办;播放中对a的战斗记录需不需要立即清除;对b的战斗需不需点击后立即开始。。。


  起初我是按照两条线程的思路来进行实现,即a的线程仍在进行,建立标志位将其停止,点击后立即开启b的线程,但实现起来非常复杂,而且有些问题不好解决,比如a的战斗记录没播放完,b已经发送了战斗请求,那么就需要停止播放a的记录,并清屏,开始播放b的战斗记录。


  后来发现,只需要一个线程即可。仅需要标记战斗目标的怪物id,战斗线程仅对标记的怪物id发送战斗请求,切换战斗目标后,因为被标记的怪物id已经变了,所以a的战斗记录播放完毕后,5秒后自动请求战斗的怪物id已变成了b,这样自动切换到了对b的战斗。从页面表现上,也更符合逻辑。


F&Q


  Q.在初始化战斗时,为什么要把玩家和怪物放到列表中?


  A.考虑后面会有组队战斗。以及战斗技能,比如法师召唤水元素,猎人带宠物。虽然目前仅是1v1,但实现时作为队伍来考虑更方便扩展。


  Q.为什么角色阵亡后,仅把其从攻方(守方)列表中移除,不从全体出手列表中移除?


  A.考虑到牧师,骑士可以施放复活技能,阵亡后的角色仍保留在列表中,对性能影响不大,方便以后技能的实现。


附-经验值计算


经验值计算


效果演示


微信图片_20220423220601.gif


本章小结


  注意,之前数据库和模型有个列名的单词写错了,我在源码中修正了。


  即map_mob的护甲字段,应为armour,之前写成了amour。如需运行源码,请先修正数据库中的列名。

 

  至此,游戏最重要的战斗功能已有了。


  后面可以开始逐步扩展背包,装备,掉落,随机附魔等重要功能。


 创建角色时,请选择 人类 - 战士, 因为其他种族和职业的数值没有配置。



相关文章
|
11月前
|
存储 区块链
无聊猿大逃杀游戏卷轴模式系统开发逻辑步骤
区块链的去中心化,数据的防篡改,决定了智能合约更加适合于在区块链上来实现
|
移动开发 前端开发 JavaScript
赛车游戏——【极品飞车】(内含源码inscode在线运行)
赛车游戏——【极品飞车】(内含源码inscode在线运行)
赛车游戏——【极品飞车】(内含源码inscode在线运行)
|
Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏07游戏输入管理
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏07游戏输入管理
115 0
|
Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏03全屏显示游戏窗口
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏03全屏显示游戏窗口
147 0
|
数据可视化 Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏15之拳击范围
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏15之拳击范围
168 0
|
数据可视化 Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏14之人身攻击范围指定与获取
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏14之人身攻击范围指定与获取
132 0
|
Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏18玩家攻击动画实现
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏18玩家攻击动画实现
156 0
|
Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏06加载游戏背景
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏06加载游戏背景
125 0
|
Java
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏13之英雄不要走出屏幕
手把手一步一步教你使用Java开发一个大型街机动作闯关类游戏13之英雄不要走出屏幕
133 0