本章初步实现游戏的核心功能——战斗逻辑。
战斗系统牵涉的范围非常广,比如前期人物的属性、怪物的配置等,都是在为战斗做铺垫。
战斗中,人物可以施放魔法、技能,需要技能系统支持。
战斗胜利后,进行经验、掉落结算。又需要背包、装备系统支持。装备系统又需要随机词缀附魔系统。
可以说是本游戏最硬核的系统。
因为目前技能、背包、装备系统都还没有实现。我们先初步设计实现一个简易战斗逻辑。
战斗动作仅包括普通攻击,有可能产生未命中、闪避和暴击。
整个战斗逻辑的流程大致如下图所示:
一、战斗消息设计
参照其他消息,战斗动作需要发送请求并接收返回消息,我们先定义两个消息代码 :
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图如下,具体实现可以下载源代码查看。
三、战斗机制
模型建完,就剩战斗逻辑了。其中,一个核心的问题就是战斗动作的判定。即发起一次普通攻击后,到底是被闪避了,还是被格挡了,还是产生了暴击,或者仅仅是命中。其中,每一项可能的结果需要单独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.考虑到牧师,骑士可以施放复活技能,阵亡后的角色仍保留在列表中,对性能影响不大,方便以后技能的实现。
附-经验值计算
经验值计算
效果演示
本章小结
注意,之前数据库和模型有个列名的单词写错了,我在源码中修正了。
即map_mob的护甲字段,应为armour,之前写成了amour。如需运行源码,请先修正数据库中的列名。
至此,游戏最重要的战斗功能已有了。
后面可以开始逐步扩展背包,装备,掉落,随机附魔等重要功能。
创建角色时,请选择 人类 - 战士, 因为其他种族和职业的数值没有配置。