在一篇文章中,我更多的是从游戏理论的角度,讨论了战斗的系统的设计。这篇文章中,我将从程序的角度,以一款航海类游戏为例,实现战斗系统。
在航海类游戏中,战斗角色是出海的船只,一次出海的船只的数量有限定,船只可以装配火炮,护甲,船帆等装备,船只还可以通过装配船长来获取技能。技能的发动是有概率的。战斗规则是,在20个回合内,如果把对方所有的船只击沉,即赢得战斗胜利,否则未平局。
战斗流程大致是这样:
这个开炮的具体的算法,我会在后面详细的贴出代码。
当我们了解了战斗的大致情况,并把所有的准备工作做好之后 (选择了参战的船只, 船只搭配了装备,选择了船长),还需要考虑的事情有如下几点:
对手:在航海类游戏中,对手有几种:常规玩家,常规NPC, 世界BOSS,海盗等。不同的对手有某些细微的差别,例如,和玩家对战的时候,需要检测玩家的参战条件,和NPC对战的时候不需要。
技能:技能的类型可能有多种,每一种技能都需要考虑到技能的释放对象,技能的影响,判断技能是否发动成功等。
战斗的核心行为:设置攻击者,防御者,执行伤害程序等。
战斗的辅助操作:寻找舰队中目前存活的第一艘船,获取舰队中现在还存活船只的数目等。
战斗奖赏:处理每次战斗的奖赏。
战斗结算:不同的对手,结算的方式不同,最后提示的信息也不同。
单次战斗记录:需要记录战斗的信息,便于前端解析这些信息,呈现战斗动画
战斗观察员:记录整场战斗的信息,便于战斗的回放。
前端表现:解析战斗信息,播放战斗动画。
针对以上的需要考虑的事情,我们可以做出多个类,每个类都有特定的职责,实现这些类中方法的思维过程就是把一个复杂的战斗动作拆分成各种小动作。这是一种组合类的设计方式。由于这个类图画的过于大,无法贴图,只有做出链接。
战斗系统类图
在战斗模型父类里面,主要实现的战斗的初始化工作和战斗的具体实现流程。
/ * 战斗进行(外观模式) * * @return const */ public function process() { // 悬赏战初始化 if ($this->_inBounty) { $this->_initBounty(); } // 创建战斗记录器 $this->_recorder = new Model_Battle_Recorder($this->_self, $this->_enemy); // 船只初始化 $this->_initShips(); // 船长初始化 $this->_initCaptains(); // 战斗进行 $this->_result = $this->_process(); // 通知记录器记录 战斗结果 $this->_recorder->setResult($this->_result); // 默认显示“再次攻击”按钮 $this->_recorder->setData('isAllowCombatAgain', true); // 新增一场战斗记录 $this->_battleId = $this->_recorder->create(); return $this->_result; }
这里最最要的方法当然是_process()了,它是战斗流程的入口。根据以上我画出来的战斗流程图,这个函数需要做的事情有如下几点:
1,如何确定回合制度。
2,如何双方的船交替开火权。
3,如何找到该开火的船只,并找到它的攻击对象。
4,什么时候对舰队做出整理,剔除沉没的船只。
5,单次战斗的结算,如扣血,技能发动。
6,怎样判定对方所有的船只都沉没了。
/ * 战斗进行 * * @return const Model_Battle_VS_Abstract::WIN/LOSE/DRAW */ protected function _process() { // 循环N个回合 for ($round = 1; $round <= self::MAX_ROUNDS; $round++) { // 根据“舰队射速”决定谁先开火(返回 self/enemy) $curFireTurn = Model_Battle_Util::calcFirePriority($this->_selfShips, $this->_enemyShips); // 每回合开始,双方重整舰队(剔除被击沉的船) $this->_selfShips = Model_Battle_Util::filterSankShips($this->_selfShips); $this->_enemyShips = Model_Battle_Util::filterSankShips($this->_enemyShips); // 过滤掉已失效的buff Model_Battle_InSkill::filterExpiredBuffs($this->_selfShips); Model_Battle_InSkill::filterExpiredBuffs($this->_enemyShips); $maxShipCount = max(count($this->_selfShips), count($this->_enemyShips)); // 每艘船交替开火(1个回合内,每艘船有仅只有一次开火权) for ($shipNo = 0; $shipNo <= $maxShipCount; $shipNo++) { // 循环两次来实现“射速”先后手的逻辑 for ($i = 0; $i < 2; $i++) { // 我船攻击敌船 if ($curFireTurn == 'self') { if (Model_Battle_Util::isShipAliveByNo($this->_selfShips, $shipNo)) { // 我方胜利:找不到下一个攻击目标了(即对方船全被击沉) if (! Model_Battle_Util::getAliveShipCount($this->_enemyShips)) { return self::WIN; } // 执行攻击流程 $this->__attackProcess($round, $this->_selfShips[$shipNo], 'self'); } // 敌船攻击我船 } elseif ($curFireTurn == 'enemy') { if (Model_Battle_Util::isShipAliveByNo($this->_enemyShips, $shipNo)) { // 对方胜利:找不到下一个攻击目标了(即我方船全被击沉) if (! Model_Battle_Util::getAliveShipCount($this->_selfShips)) { return self::LOSE; // 对方的胜利即我方的失败 } // 执行攻击流程 $this->__attackProcess($round, $this->_enemyShips[$shipNo], 'enemy'); } } // 下一次开火权交给对方 $curFireTurn = $curFireTurn == 'self' ? 'enemy' : 'self'; } } } // 双方战平:N回合内仍未见胜负 return self::DRAW; }
为什么每个回合开始前,都需要重新整理舰队,因为上一个回合可能是某些船沉没了,我们必须剔除那些已经被沉没了船只。
至于交替开火权,我们这里做了一个好的思维方式是,把开火权做成一个变量,并循环赋值。
如何找到开火的船只,我们的做法是把舰队里面的船编号,从0-maxshipamount, 循环这些船只,根据编号就可以找到要开火的船只。这里或许你有个问题是,比如,我第一艘船开火,把你的第一艘船击沉了,那么,该你开会的时候,你的第一艘船上没有船只了,按照我程序上面写的,如果根据编号找不到船,那么开火权又交给他对方,这样,又该我开火了。其实这样做也可以,但是我们不是这样做的,我们的做法是,如果我击沉了你的0号位上的船,那么你的1号位的船自动变为0号位上的。 要实现这样的功能,需要,在执行开火之后,再一次整理船只。
关于寻找攻击对象,就是找对方第一艘存活的船只,每次攻击的时候,实际上都是攻击0号位的船,这里大家或许又有一个疑问,既然每次都是攻击0号位置上的船只,那么1号位置的船谁来攻击,大家别忘了重整船只后,1号位置会补充到2号位置上去的。
具体的开火代码如下:
/ * 进一步细化的攻击流程(可供双方调用) * * @param int $round 第几回合 * @param Model_Ship $attackerShip 攻船实例 * @param string $attackerSide self/enemy * @return void */ protected function __attackProcess($round, Model_Ship $attackerShip, $attackerSide) { // 如果攻船正处于“混乱”状态(即什么都不能做)直接略过本次攻击逻辑 if (Model_Battle_InSkill::isFrozen($attackerShip)) { return null; } // 在攻击前,攻船是否因为DOT而沉没 $attackerShipIsSank = false; // 攻方信息 $attacker = $this->{'_' . $attackerSide}; // 设置本次攻击的攻船信息 $logger->setRound($round) ->setAttackerShip($attackerShip) ->setAttackerUid($attacker['uid']); // 守方是谁 $defenderSide = $attackerSide == 'self' ? 'enemy' : 'self'; // 攻防双方舰队数组 $attackerShips = Model_Battle_Util::filterSankShips($this->{'_' . $attackerSide . 'Ships'}); $defenderShips = Model_Battle_Util::filterSankShips($this->{'_' . $defenderSide . 'Ships'}); // 如果攻船正处于“封印”状态,则不能发动技能 if (Model_Battle_InSkill::isSealed($attackerShip)) { $skillId = 0; } // 否则可以触发攻船的战斗内技能 else { // 返回0表示触发失败,触发成功则返回技能Id $skillId = Model_Battle_InSkill::triggerSkill($attacker, $attackerShip); } // 普通攻击 if ($skillId < 1) { // 定位受击对象(对方舰队中存活的第一艘船) $defenderShip = Model_Battle_Util::findFirstAliveShip($defenderShips); // 执行开火 $fire = new Model_Battle_Fire(); $fire->setLogger($logger) ->setAttacker($attackerShip) ->setDefender($defenderShip) ->execute(); // 记录受击方 $logger->setTargetShip($defenderShip); // 技能攻击 } else { // 创建本次技能实例 $skill = Model_Battle_InSkill::factory($skillId); // 设置双方舰队 $skill->setSelfShips($attackerShips); $skill->setEnemyShips($defenderShips); // 设置技能发动母体船 $skill->setAttacker($attackerShip); // 获取受技能方(们) $targetShips = $skill->getTargetShips(); // 是否攻击(伤害)型技能 if ($skill->isAttack()) { // 执行群伤开火 foreach ($targetShips as $targetShip) { $fire = new Model_Battle_Fire(); $fire->setLogger($logger) ->setAttacker($attackerShip) ->setDefender($targetShip) ->setSkill($skill) ->execute(); } } // 设置船只buffs $skill->setBuffs(); // 通知记录器记录技能相关信息 $logger->setSkill($skill); // 记录受技能方(们) $logger->setTargetShips($targetShips); } // 通知战场观察员增加本条开火记录 $this->_recorder->add($round, $logger->build()); }
开战之前,也要重整舰队,理由上面我已经说了。
这个函数会依赖很对类,战斗助手类,战斗技能类等。这里我们可以参考的一个设计思维是,多用组合,少用继承。这样我们可以便于扩展和修改,缺点是可能类的数量为增加。