从零开始实现放置游戏(十五)——实现战斗挂机(6)在线打怪练级
丶谦信 人气:2 本章初步实现游戏的核心功能——战斗逻辑。
战斗系统牵涉的范围非常广,比如前期人物的属性、怪物的配置等,都是在为战斗做铺垫。
战斗中,人物可以施放魔法、技能,需要技能系统支持。
战斗胜利后,进行经验、掉落结算。又需要背包、装备系统支持。装备系统又需要随机词缀附魔系统。
可以说是本游戏最硬核的系统。
因为目前技能、背包、装备系统都还没有实现。我们先初步设计实现一个简易战斗逻辑。
战斗动作仅包括普通攻击,有可能产生未命中、闪避和暴击。
整个战斗逻辑的流程大致如下图所示:
一、战斗消息设计
参照其他消息,战斗动作需要发送请求并接收返回消息,我们先定义两个消息代码 :
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 BattleMobResult implements Serializable { // 总回合数 private Integer totalRound; // 回合列表 private List<BattleRound> roundList; // 是否玩家获胜 private Boolean playerWin; // 战斗结果信息 private String resultMessage; public BattleMobResult() { this.roundList = new ArrayList<>(); } public void putBattleRound(BattleRound battleRound) { this.roundList.add(battleRound); } }
@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,具体实现代码如下:
package com.idlewow.game.logic.battle; import com.idlewow.character.model.Character; import com.idlewow.character.model.LevelProp; import com.idlewow.character.service.CharacterService; import com.idlewow.character.service.LevelPropService; import com.idlewow.common.model.CommonResult; import com.idlewow.game.GameConst; import com.idlewow.game.logic.battle.dto.BattleMonster; import com.idlewow.game.logic.battle.dto.BattlePlayer; import com.idlewow.game.logic.battle.util.ExpUtil; import com.idlewow.game.hub.message.vo.battle.BattleMobResult; import com.idlewow.game.logic.battle.dto.BattleUnit; import com.idlewow.game.logic.battle.util.BattleUtil; import com.idlewow.game.hub.message.vo.battle.BattleRound; import com.idlewow.mob.model.MapMob; import com.idlewow.mob.service.MapMobService; import com.idlewow.support.util.CacheUtil; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.util.LinkedList; import java.util.List; import java.util.Random; @Component public final class BattleCore { private static final Logger logger = LogManager.getLogger(BattleCore.class); // 战斗最大回合数 private static final Integer MaxRound = 20; // 暴击系数 private static final Integer CriticalFactor = 2; @Autowired MapMobService mapMobService; @Autowired LevelPropService levelPropService; @Autowired CharacterService characterService; /** * 在线打怪 * * @param character * @param mobId * @return */ public BattleMobResult battleMapMob(Character character, String mobId) { // 获取地图怪物信息 CommonResult commonResult = mapMobService.find(mobId); if (!commonResult.isSuccess()) { logger.error("未找到指定怪物:id" + mobId); return null; } // 初始化参战方信息 MapMob mapMob = (MapMob) commonResult.getData(); List<BattleUnit> atkList = new LinkedList<>(); atkList.add(this.getBattlePlayer(character, GameConst.BattleTeam.ATK)); List<BattleUnit> defList = new LinkedList<>(); defList.add(this.getBattleMonster(mapMob, GameConst.BattleTeam.DEF)); List<BattleUnit> battleList = new LinkedList<>(); battleList.addAll(atkList); battleList.addAll(defList); battleList = BattleUtil.sortUnitBySpeed(battleList); // 回合循环 BattleMobResult battleMobResult = new BattleMobResult(); for (int i = 0; i < MaxRound; i++) { BattleRound battleRound = new BattleRound(i + 1); for (BattleUnit battleUnit : battleList) { if (!battleUnit.getIsDefeat()) { // 选定攻击目标 BattleUnit targetUnit = null; if (battleUnit.getTeam().equals(GameConst.BattleTeam.ATK)) { Integer targetIndex = new Random().nextInt(defList.size()); targetUnit = defList.get(targetIndex); } else if (battleUnit.getTeam().equals(GameConst.BattleTeam.DEF)) { Integer targetIndex = new Random().nextInt(atkList.size()); targetUnit = atkList.get(targetIndex); } // 攻方出手ROLL点 Integer roll = new Random().nextInt(100); Double miss = (1 - battleUnit.getHitRate() / (battleUnit.getHitRate() + battleUnit.getDodgeRate())) * 100; Double critical = battleUnit.getCriticalRate() * 100; logger.info("round: " + i + "atk: " + battleUnit.getName() + " def: " + targetUnit.getName() + " roll:" + roll + " miss: " + miss + " cri: " + critical); String desc = ""; if (roll <= miss) { desc = battleUnit.getName() + " 的攻击未命中 " + targetUnit.getName(); } else if (roll <= miss + critical) { Integer damage = BattleUtil.actualDamage(battleUnit.getAP(), targetUnit.getDR()) * CriticalFactor; desc = battleUnit.getName() + " 的攻击暴击,对 " + targetUnit.getName() + " 造成 " + damage + " 点伤害(" + targetUnit.getHp() + " - " + damage + " )"; targetUnit.setHp(targetUnit.getHp() - damage); } else { Integer damage = BattleUtil.actualDamage(battleUnit.getAP(), targetUnit.getDR()); desc = battleUnit.getName() + " 的攻击,对 " + targetUnit.getName() + " 造成 " + damage + " 点伤害(" + targetUnit.getHp() + " - " + damage + " )"; targetUnit.setHp(targetUnit.getHp() - damage); } // 检测守方存活 if (targetUnit.getHp() <= 0) { targetUnit.setIsDefeat(true); desc += ", " + targetUnit.getName() + " 阵亡"; if (battleUnit.getTeam().equals(GameConst.BattleTeam.ATK)) { defList.remove(targetUnit); } else if (battleUnit.getTeam().equals(GameConst.BattleTeam.DEF)) { atkList.remove(targetUnit); } } else { // 检测守方反击动作 // todo } battleRound.putMessage(desc); // 检测战斗结束 if (atkList.size() == 0 || defList.size() == 0) { Boolean playerWin = defList.size() == 0; battleRound.setEnd(true); battleMobResult.setTotalRound(i); battleMobResult.setPlayerWin(playerWin); String resultMessage = "战斗结束! " + character.getName() + (playerWin ? " 获得胜利!" : " 不幸战败!"); battleMobResult.putBattleRound(battleRound); battleMobResult.setResultMessage(resultMessage); // 玩家获胜 进行战斗结算 if (playerWin) { // 经验结算 this.settleExp(character.getLevel(), mapMob.getLevel(), character); // 更新角色数据 characterService.updateSettle(character); } return battleMobResult; } } } battleMobResult.putBattleRound(battleRound); } battleMobResult.setTotalRound(MaxRound); battleMobResult.setResultMessage("战斗回合数已用尽!守方获胜!"); return battleMobResult; } /** * 经验值结算 * @param charLevel 角色等级 * @param mobLevel 怪物等级 * @param character 角色信息 */ private void settleExp(Integer charLevel, Integer mobLevel, Character character) { Integer exp = ExpUtil.getBattleMobExp(charLevel, mobLevel); if (exp > 0) { Integer levelUpExp = CacheUtil.getLevelExp(charLevel); if (character.getExperience() + exp >= levelUpExp) { character.setLevel(charLevel + 1); character.setExperience(character.getExperience() + exp - levelUpExp); } else { character.setExperience(character.getExperience() + exp); } } } /** * 获取角色战斗状态 * @param character 角色信息 * @param battleTeam 所属队伍 * @return */ private BattlePlayer getBattlePlayer(Character character, String battleTeam) { LevelProp levelProp = levelPropService.findByJobAndLevel(character.getJob(), character.getLevel()); BattlePlayer battlePlayer = new BattlePlayer(); battlePlayer.setId(character.getId()); battlePlayer.setName(character.getName()); battlePlayer.setJob(character.getJob()); battlePlayer.setLevel(character.getLevel()); battlePlayer.setHp(levelProp.getHp()); battlePlayer.setStrength(levelProp.getStrength()); battlePlayer.setStamina(levelProp.getStamina()); battlePlayer.setAgility(levelProp.getAgility()); battlePlayer.setIntellect(levelProp.getIntellect()); battlePlayer.setTeam(battleTeam); return battlePlayer; } /** * 获取怪物战斗状态 * @param mapMob 怪物信息 * @param battleTeam 所属队伍 * @return */ private BattleMonster getBattleMonster(MapMob mapMob, String battleTeam) { BattleMonster battleMonster = new BattleMonster(); battleMonster.setId(mapMob.getId()); battleMonster.setName(mapMob.getName()); battleMonster.setLevel(mapMob.getLevel()); battleMonster.setHp(mapMob.getHp()); battleMonster.setDamage(mapMob.getDamage()); battleMonster.setArmour(mapMob.getArmour()); battleMonster.setTeam(battleTeam); return battleMonster; } }
如上图代码,首先我们初始化一份各参战单位的属性副本,并添加到创建的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.考虑到牧师,骑士可以施放复活技能,阵亡后的角色仍保留在列表中,对性能影响不大,方便以后技能的实现。
附-经验值计算
艾泽拉斯的怪物经验公式是 45+等级*5 外域的怪物经验公式是 235+等级*5 基础知识 魔兽里你选取怪物以后一般名字级别上面有颜色,它指示你和怪物之间的等级差别, 骷髅级别 怪物级别大于等于玩家级别的10级 红色 怪物级别大于等于玩家级别的5级 橙色 怪物级别大于等于玩家级别的3或者4级 黄色 怪物级别小于等于玩家级别2级和大于大于等于玩家级别2级之间 绿色 怪物级别小于玩家级别3级,但是还未变灰 灰色 玩家级别1-5级: 灰色级别小于等于0(没有灰色的怪) 玩家级别 6-39级:灰色级别小于等于 玩家级别-(玩家级别÷10取整数上限) -5 玩家级别 40-59级:灰色级别小于等于 玩家级别-(玩家级别÷5取整数上限) -1 玩家级别 60-70级:灰色级别小于等于 玩家级别-9 注:整数上限是指不小于该值的最小整数,比如4.2整数上限是5,3.0整数上限是3 单杀怪的经验 杀死灰色级别的怪是没有经验的,其他颜色级别的怪单杀的经验值计算如下:(艾泽拉斯) 相同级别的怪: 经验=(玩家等级×5+45) 高等级怪: 经验=(玩家等级×5+45)×(1+0.05×(怪物等级-玩家等级) ,当怪物等级大于玩家等级4级以上均按4级计算,哪怕精英怪 低等级怪: 有一个零差值系数ZD (zero difference value) ZD = 5, when Char Level = 1 - 7 ZD = 6, when Char Level = 8 - 9 ZD = 7, when Char Level = 10 - 11 ZD = 8, when Char Level = 12 - 15 ZD = 9, when Char Level = 16 - 19 ZD = 11, when Char Level = 20 - 29 ZD = 12, when Char Level = 30 - 39 ZD = 13, when Char Level = 40 - 44 ZD = 14, when Char Level = 45 - 49 ZD = 15, when Char Level = 50 - 54 ZD = 16, when Char Level = 55 - 59 ZD = 17, when Char Level = 60+ 经验=(玩家等级×5+45)×(1-(玩家等级-怪物等级)÷ 零差值系数) 计算的例子如下: 假设玩家等级 = 20. 那么灰名怪物等级 = 13, 根据以上表格获得. 杀掉任何 13 级或者以下的怪得不到经验。 同等级基础经验为 (20 * 5 + 45) = 145. 杀掉一个 20 级别的怪将能获得145点经验。 对于一个 21级怪, 你将获得 145 * (1 + 0.05 * 1) = 152.2 四舍五入为 152 点经验. 根据上面表格ZD值是11。 对于18级的怪, 我们将有 145 * (1 - 2/11) = 118.6 四舍五入为 119点经验。 对于16级的怪, 我们将有 145 * (1 - 4/11) = 92.3四舍五入为 92点经验. 对于14级的怪, 我们将有 145 * (1 - 6/11) = 65.91四舍五入为 66点经验. 对于燃烧的远征外域的怪,经验计算较多,笔者根据表格值推论公式如下: 相同级别的怪: 经验=(玩家等级×5+235) 高等级怪: 经验=(玩家等级×5+235)×(1+0.05×(怪物等级-玩家等级) ,当怪物等级大于玩家等级4级以上均按4级计算,哪怕精英怪。 低等级怪: 经验=(玩家等级×5+235)×(1-(玩家等级-怪物等级)÷ 零差值系数) 精英怪经验=普通同等级怪经验×2 精力充沛时间经验=普通计算经验×2 (耗尽精力充沛点数为止,故而最后一个精力充沛期间杀的怪未必能达到经验×2) 影响杀怪经验的因素 对于大号带小号或者抢怪的情况,玩家杀怪的经验值就会有变化。 一般来说,原则如下: 如果你开怪并造成了伤害,那么怪物就是你的;这个时候,如果有别的玩家或者大号来杀了这个怪,那么如果帮助杀怪的人对于这个级别的怪他能够获得经验,则属于抢怪,不会影响你的经验获得; 如果帮助你杀怪的人对于这个级别的怪或不得经验,那么就是就属于带小号了,你获得很少很少的经验,非常的不划算。因此,对于一个60级的玩家来带小号,不管什么级别的怪,他都没有经验(TBC以前),所以小号获得的经验非常少!而如果是59的玩家帮忙杀50+的怪,那么经验都是小号的! 战斗中如果有别人帮你加血,加血只会扣掉你很少的经验,用大号跟随加血小号练级是不错的办法;别人给你加的伤害护盾(比如说荆棘术什么的)只会影响你非常少的经验,5-10点最坏情况,基本可以无视了,放心的加吧。 综上所述,用不满级的相同等级区间里的号带小号效率最高,比如49的带40的,59的带50的…… 但是,大号基本都是60的,没办法,呵呵,只能帮带任务或者副本了。 组队经验值 根据wiki的资料,这个只是推论,未必精确 假设一个队伍中的人都是同等级的,那么 每个人的经验=单杀怪的经验÷人数×系数 系数是: 1人:1 2人:1 3人:1.166 4人:1.3 5人:1.4 例子如下: 杀100经验的怪 1人 = 100xp 2人 = 50xp 每人. 3人 = ~39xp 每人. 4人 = ~33xp 每人. 5人 = ~28xp 每人. 两人队伍计算公式 假设 玩家1级别>玩家2级别 那么 基础经验按玩家1级别计算 最后分得的经验 玩家1获得 基础经验×玩家1级别÷(玩家1级别+玩家2级别) 玩家1获得 基础经验×玩家2级别÷(玩家1级别+玩家2级别) 团队里面经验值要打折(除以2)
效果演示
本章小结
注意,之前数据库和模型有个列名的单词写错了,我在源码中修正了。
即map_mob的护甲字段,应为armour,之前写成了amour。如需运行源码,请先修正数据库中的列名。
至此,游戏最重要的战斗功能已有了。
后面可以开始逐步扩展背包,装备,掉落,随机附魔等重要功能。
本文原文地址:https://www.cnblogs.com/lyosaki88/p/idlewow_15.html
本章源码下载地址:https://474b.com/file/14960372-444702415
项目交流群:329989095
加载全部内容