技能系统
unity/c++ mmo大型多人在线游戏开发系列教程,本节代码在此。
在之前的实现里,我们已经实现了一个可以多人游玩的世界,但角色在场景里只能移动,现在是时候加一点技能来玩了。

普攻
我们先来实现普攻。
普攻也是技能的一种,把它与一般的技能分开,是因为普攻有攻速的限制,而一般技能是用cd来限制的。同时,普攻会有连击这一说,一般技能是没有的。
整个流程很简单,客户端发起普攻,本地先行播放普攻动画,同时告知服务器发起普攻。服务端收到请求后,执行具体的普攻逻辑,就是简单地选取敌人,执行伤害。
// 客户端
public class CombatComponent : MonoBehaviour
{
private int comboSeq = 0;
private bool comboPressed = false;
private float nextNormalAttackTime = 0;
public void NormalAttack()
{
if (NetworkManager.Instance.GetServerTime() < nextNormalAttackTime)
return;
if (!EnableNormalAttack)
{
if (EnableComboAttack)
{
comboPressed = true;
}
return;
}
// 攻速限制,普攻间隔 = 1 / (基础攻速 + 攻速加成),AttackSpeed的值表示1秒可以攻击多少次
float interval = 1.0f / (_attrSet.AttackSpeed + _attrSet.AdditionalAttackSpeed);
nextNormalAttackTime = Time.time + interval;
// 普攻动画播放速率根据攻速发生变化
float playRate = (_attrSet.AttackSpeed + _attrSet.AdditionalAttackSpeed) / _attrSet.AttackSpeed;
_anim.speed = playRate;
// 普攻支持连击,目前直接在编辑器中配置了combo的动画列表
string animClip = ComboClips[comboSeq];
_anim.CrossFade(animClip, 0.2f * playRate);
// 普攻默认先行,并且不做回滚,毕竟间隔很短,也不耗蓝
SpaceService.NormalAttack req = new SpaceService.NormalAttack
{
Combo = comboSeq,
};
NetworkManager.Instance.Send("normal_attack", req.ToByteArray());
EnableNormalAttack = false;
comboSeq = (comboSeq + 1) % ComboClips.Count;
}
}
// 服务端
void CombatComponent::normal_attack(int combo_seq)
{
if (combo_seq < 0 || combo_seq >= sizeof(combo_animations) / sizeof(const char*))
return;
size_t now = G_Timer.ms_since_start();
if (now < _next_normal_attack_time) {
spdlog::error("normal attack cd limit, _next_normal_attack_time: {}, now: {}", _next_normal_attack_time, now);
return;
}
float interval = 1.0f / (_attr_set.attack_speed + _attr_set.additional_attack_speed);
_next_normal_attack_time = now + static_cast<size_t>(interval * 1000);
float play_rate = (_attr_set.attack_speed + _attr_set.additional_attack_speed) / _attr_set.attack_speed;
_owner->play_animation(combo_animations[combo_seq], play_rate);
// TODO 普攻效果暂定为: 0.5秒后对处于面前2米60度扇形区域内的敌人造成10点伤害
_normal_attack_timer = G_Timer.add_timer(500, [this]() {
// FIXME 指针安全
Vector3f center = _owner->get_position();
Rotation rot = _owner->get_rotation();
float ux = sinf(rot.yaw * DEG2RAD);
float uz = cosf(rot.yaw * DEG2RAD);
Space* space = _owner->get_space();
std::vector<Player*> others = space->find_players_in_sector(center.x, center.z, ux, uz, 2.f, 30.f * DEG2RAD);
for (Player* other : others) {
if (other == _owner)
continue;
CombatComponent* comp = other->get_component<CombatComponent>();
if (comp)
comp->take_damage(this, 10);
}
_normal_attack_timer = -1;
});
}
技能
每个技能的逻辑可能千差万别,但启动到销毁的流程是一致的。为此,创建了一个简单的技能继承结构:
// 技能基类
class CombatComponent;
class ISkill {
public:
virtual ~ISkill() = default;
virtual void reset() { set_active(true); }
virtual void destroy() {}
virtual void execute() = 0;
void set_owner(CombatComponent* owner) { _owner = owner; }
CombatComponent* get_owner() { return _owner; }
inline void set_active(bool active) { _is_active = active; }
inline bool is_active() { return _is_active; }
private:
bool _is_active = true;
CombatComponent* _owner = nullptr;
};
// 具体的技能实现
class Skill_1 : public ISkill {
public:
virtual void destroy() override;
virtual void execute() override;
private:
int _effect_timer = -1;
};
流程上,客户端监听玩家的输入,通知服务端执行技能:
public class CombatComponent : MonoBehaviour
{
public void CastSkill(int skillIndex)
{
if (!CanCastSkill(skillIndex)) return;
SkillInfo skillInfo = skillInfos[skillIndex];
int skillId = skillInfo.SkillId;
// 本地先行
if (skillInfo.LocalPredicted)
{
// 目前回滚仅涉及cd和mana,因此服务端在收到请求时,无论是否成功,都下发最新的cd与mana即可。
skillInfo.NextCastTime = NetworkManager.Instance.GetServerTime() + skillInfo.CoolDown;
_attrSet.Mana -= skillInfo.CostMana;
_anim.CrossFade(skillInfo.AnimatorState, 0.2f);
}
SpaceService.SkillAttack req = new SpaceService.SkillAttack
{
SkillId = skillId,
};
NetworkManager.Instance.Send("skill_attack", req.ToByteArray());
}
}
服务器收到请求后,通过技能工厂创建类实例,然后触发技能逻辑,并广播相应的效果:
void CombatComponent::cast_skill(int skill_id)
{
auto iter = std::find_if(_skill_infos.begin(), _skill_infos.end(), [skill_id](const SkillInfo& info) {
return info.skill_id == skill_id;
});
if (iter == _skill_infos.end()) {
spdlog::error("cast skill but skill {} not found!", skill_id);
return;
}
SkillInfo& info = *iter;
if (can_cast_skill(info)) {
// reduce cost
_attr_set.mana -= info.cost_mana;
info.next_cast_time = int(G_Timer.ms_since_start()) + info.cool_down;
ISkill* skill = get_or_create_skill_instance(skill_id, info.instance_per_entity);
skill->execute();
}
// TODO 有个属性同步机制就好了
// 更新客户端cd
sync_skill_info(info);
// 更新客户端蓝量
sync_attr_set();
}
预测与回滚
技能实现原则上要尽可能保证玩家的体验,一大要求就是对玩家操作的响应要及时,因此技能应该尽可能支持本地先行,不然按个技能要等一个RTT才能看到效果。
PS. 帧同步的游戏确实不可避免地有这个延迟,甚至更高,以后有机会再细说。
但这是预测,客户端自己先预测了这个技能可以执行,并且预测了消耗与CD的变化。服务端在收到这个技能请求时可能根本就不会通过,例如玩家被人沉默了。就算可以执行,消耗与CD的变化也不一定与客户端预测的一致,例如玩家被加上了一些增益buff。
因此,又要处理可能产生的不一致了。我所了解的有两个比较完善的方案,一个是ECS,一个是GAS。
ECS是一个架构啦,技能的预测回滚只是在其中的一种应用,或者说预测回滚在这种架构下实现起来很方便。简单来说,客户端本地要记录每一帧玩家的输入,以及输入处理后的状态,然后将输入上传给服务端。服务端自然也在每帧对状态进行更新,注意这个更新并不完全来源于主客户端的上传,否则就没有不一致这一说了。可以认为服务端也会把自己每帧的状态同步给客户端,客户端收到后,取出本地记录的那一帧的状态与之对比,如果相同,直接把缓存删除即可,如果不同,则将状态回滚到该帧,并重放后续的输入。
在ECS的架构下,可以很方便的实现状态的对比与回滚,因此天然就很适合实现预测与回滚,当然代价就是你要使用这个架构,具体可以看看gdc上暴雪的分享。
GAS就是UE的技能系统插件了。跟ECS不一样,它针对的是一次技能的效果来进行预测与回滚,而不是针对每一帧的状态。简单来说,在每次技能激活时,它会在客户端本地生成一个预测key,客户端本地预测先行的每一个效果都与这个key进行关联,设置好当收到服务端确认结果后的操作。每一种效果知道自己要怎么处理,例如数值类的修改,那就是针对属性值进行操作,动画类的效果就是对动画进行启停。
但这只是简单来说,GAS还有很多细节与限制,具体可以看看源码中GameplayPrediction.h开头的注释。
可以看到,上面技能的代码勉强算是支持预测与回滚的,如果该技能配置为本地先行模式,则客户端会先自主扣除消耗、设置CD、触发动画,所以对玩家的输入是即时的。
这里的预测回滚就更简单了(也更简陋),仅仅是针对蓝量与CD两个属性进行预测与回滚,因此并不需要什么架构或框架,简单特殊处理一下即可。
伤害结算
技能实现的另一个原则是要尽可能防止作弊,因此伤害结算由服务端来进行。
但这就跟前面的客户端本地先行产生了一些冲突,在高延迟下可能出现客户端技能动画都表现完了,本应打中的敌人却迟迟没产生受击反应。
一种处理方式是,让服务端把技能结算点提前二分一的RTT时间,但这对受击方来说有点不公平。
假设客户端A的攻击请求在时间t到达服务端,客户端B会在(t + 0.5 * RTT)时看到这个攻击。假设B的反应时间为ReactionTime,则B的反应会在(t + 0.5 * RTT + ReactionTime + 0.5 * RTT)时到达服务端,也就是说如果:
t + 0.5 * RTT + ReactionTime + 0.5 * RTT < t + 技能前摇时间
即
ReactionTime < 技能前摇时间 - RTT
则B能躲过这一次攻击。而假如把伤害结算提前了半个RTT,则需要:
t + 0.5 * RTT + ReactionTime + 0.5 * RTT < t + 技能前摇时间 - 0.5 * RTT
即
ReactionTime < 技能前摇时间 - 1.5 * RTT
B才能躲过这一次攻击,反应时间少了半个RTT。
动画
动画的切换我都是直接用animator.CrossFade
做的,所以把技能动画添加到动画状态机中即可,为了更清晰,在base layer
之上新建了一层combat layer
:

同时,利用Unity动画中的event功能,可以实现技能的打点,例如在技能的某个时间段开放连招窗口:

root motion
有些技能动画是带位移的,目前我们并没有root motion的同步机制,采用的依然是之前的那一套普通的位置同步。因此,simulate端的技能动画表现上可能并没有那么完美。无论如何,就算是不完美,要让它能正常跑起来,也需要改一些东西。
首先是simulate的charater不能apply root motion,否则root motion产生的位移会和网络同步过来的位移产生冲突,因此,将OnAnimatorMove
从CharacterMovement
移到了PlayerController
中。
然后是在root motion控制位移的时候,不能设置动画状态机的speed,否则动画会在普通移动与技能动画中冲突:
void Update()
{
if (SyncMovementMode == ESyncMovementMode.Interpolate)
{
InterpolateMovement();
}
else if (SyncMovementMode == ESyncMovementMode.Predict)
{
PredicteMovement();
PredictRotate();
}
// root motion控制位移时不能设置speed,否则会触发正常的移动动画,与root motion产生冲突
if (_characterMovement.EnableMovement)
{
Vector3 horizontalV = new Vector3(_curVelocity.x, 0, _curVelocity.z);
_characterMovement.SetSpeed(horizontalV.magnitude);
}
_characterMovement.UpdateAnimation();
}
小结
现在我们有了基础的战斗框架,玩家可以互相攻击,并看到攻击的效果。但是,有很多地方可以继续深挖,一方面是战斗的表现,可以加上一些战斗特效、震屏之类的让战斗更好看。另一方面是技能的效果,目前只有基础的直接伤害,可以加上各种诸如晕眩、击飞之类的效果。还有就是配置的工作,目前很多数据是直接写死在代码里的,这并不正确。
但是可以先放一放,在写这一节的代码时,我发现有些基础的功能是时候先完善一下了,比较明显的是Space::find_players_in_circle
,目前需要遍历场景里的所有玩家,既然说是mmo,那人数一多,性能是受不了的。另外一个是,由于缺少属性同步机制,导致很多属性的变化都需要rpc来解决。后面我们先来解决这两个问题。