跳转至

前言 技术总结

  • 帧同步核心原理
  • 基于Kcp实现前后台通用网络库;
  • 多路复用Select收发网络消息;
  • 可扩展网络通讯协议;
  • 实用客户端框架设计;
  • 实用服务端逻辑框架;
  • 完整的房间匹配、确认、选英雄流程;
  • 客户端逻辑与表现分离;
  • 子弹体积扫描算法(Sweep Volume)
  • 移动、转向插值平滑;
  • 角色行为预测及校正;
  • 轮盘摇杆制作,UI点击特效;
  • 定点数运算数学库;
  • 确定性物理引擎碰撞算法;
  • 基于配置驱动的可扩展技能系统
    • 技能结构
    • 技能状态:
    • 技能UI操作。
    • 目标查找:
    • 非目标和目标技能:
    • 延迟和非延迟技能
    • 前摇
    • 技能执行:
      • 技能生效伤害计算
      • 附加buff
      • 后摇处理
  • 基于配置驱动的可扩展Buff系统
    • buff结构
    • Buff生命周期
    • 逻辑更新
  • 手感提升:移动攻击(全局BUFF技能),以及UI输入打断,
  • 音效加载及管理;
  • 对象池缓存技术;
  • 血条、飘字动态追踪与适配;

待实现部分: - 可靠UDP通讯实现; - 确定性三角函数级数查表; - 完整实现两个英雄(亚瑟和后羿)所有8个技能效果; - 完整实现技能涉及的所有Buff效果(增益、减益); - 小兵、防御塔AI逻辑; - 小地图位置同步显示; - 心跳机制检测网络时延; - 阿里云服务器环境部署; - 多平台(Linux、Windows)发布流程;

一 客户端与服务端框架

1.1 客户端框架

  • GameRoot
    • 服务Service:网络、资源、音频
    • 系统System:战斗、大厅、登录

二 基础服务器和客户端连接、主要界面制作

  • 采用KCP进行通讯,构建单独的KCP网络库
  • 嵌套通信协议:Serializeble,重复利用,实现一个类存储所有消息,利用枚举作区分。
    • 错误码:用于服务端错误检验。
    • 具体的协议内容定义在构造时赋值。
  • 服务端处理登录请求:消息分发,根据消息CMD类型选择不同系统的相关函数。
    • 登录功能:CacheSvc缓冲user信息并提供相关方法,LoginSys判断
  • 客户端处理登录请求:依然是通过消息分发HandoutMsg。
    • 处理登录消息:GameRoot提供用户数据缓存,拿到userdata存到客户端去。
  • LoginSys,通过系统类来处理不同的UI:
    • 登录成功后,切换到开始游戏界面UI。
    • 开始游戏后,进入到游戏大厅界面UI。 UI管理策略:各个系统单独分成单例。UI界面之间最好不应该有相互的引用,跳转逻辑最好交由系统单例类负责。每个UI只管理自己的数据和交互。 同理:跨系统的UI界面就更不能互相引用了,应该通过系统进行相关的逻辑转换。 当然,所有系统都应该继承自一个基类,其中设置了根节点,并提供了各种Svc。
  • 匹配系统MatchSys:点击匹配按键时,发送对应的匹配类型请求,收到后,顺带发送预估时间信息,客户端收到后开始匹配。
  • PVPRoom对战房间:只记录roomID,房间对战类型和Session连接的数组
  • RoomSys提供房间管理,全局房间ID分配,
  • 匹配系统UPdate检测当前匹配队列人数,人数满足后则开始匹配。

三 处理对战

  • 状态模式处理对战流程
  • 分为五个状态:确认、英雄选择,结束(End)
  • 开始匹配需要传入确认信息、以及对应玩家的会话信息到服务器队列中。当队列满足房间创建需求,则开始创建房间:
  • 服务端调用逻辑:NetSvc分发消息->RoomSys处理对应消息(分配给具体的房间)->PVPRoom根据当前房间状态进行处理(本身就负责进行状态控制)->交给对应状态state进行具体处理->处理完后创建新的HOKMsg再Send回客户端

3.1 确认阶段

  • 确认:房间创建好后,将状态转移到Confirm状态,并通过会话将开始确认的msg,发给客户端,
  • 房间需要提供一个广播函数用来传递给房间内所有玩家。让客户端UI进入确认界面
  • 客户端进入确认界面之后点击准备按钮,发送给服务端Confirm消息,准备按钮不再被点击。
  • 服务端Handout处理该Confirm消息修改对应玩家的状态,提供一个标志bool判断是否是最后一人:
  • 若不是:再发给所有玩家一个确认信息。所有客户端对确认信息进行更新,并调整确认边框和头像。
  • 若是:
    • 当客户端检测到数量 = 房间人数时,准备进入下一个阶段了。
    • 服务器检测到后,也会进入下一个状态Select选择英雄。
  • 服务端超时验证:若匹配超时,还是广播ntfConfirm给所有玩家,只不过要设置dismiss解散字段为true,并切换房间状态为End。客户端验证后解散回到大厅。

DLC 高性能计时系统

3.2 英雄选择阶段

数据:选择数据(选择ID、选择完成) 制定协议:确认信息(房间号,英雄id),广播Select(无内容,只负责通知转状态) 其实英雄选择和匹配确认过程蛮相似的,区别比较大的也就是记录一个英雄ID而已。以及服务器不需要发送特别的数据给客户端进行显示的更新。 - 只需要发送最简单的消息告知客户端进入英雄选择界面就行。 - SelectWnd选择UI界面:实例化头像组件,挂在滚动列表根节点 - 设置配置文件:记录heroID和对应资源的映射关系,用于加载。 - 设置一系列组件,并注册点击事件。 - PEListenner监听按钮事件类:用于代码注册各种UI监听事件。 - 初始化发送NtkSelect数据,附带角色拥有的英雄ID。 - 玩家确认,发送SndSelect消息,客户端做确认处理,同时服务端也要进行验证。 - 当计时结束,倒计时完成,客户端强制默认为当前选择(发送SndSelect)。服务端这边再预留1-2s的时间等客户端的消息。如果玩家没有选择角色(或者说选人时断线了)就通过CacheSvc拿到用户数据,并且返回第一个角色作为选择的角色。 - 然后就切到Load了

3.3 加载界面

技术点:加载消息处理、异步加载地图,循环委托每帧拿进度条。进度消息发送处理 数据:战斗英雄的数据(玩家名字,英雄ID,还有皮肤边框等内容可以自定义扩展) 制定协议:加载资源(地图ID、加载英雄列表Lsit,玩家当前的位置) 状态一转换就需要发送加载资源消息不能广播。 - 客户端:处理加载资源消息,用来加载,设置好全局root的数据。并初始化战斗系统* - 初始化战斗系统LobbySys**:关闭声音,开启加载UI,获得当前地图ID,异步加载地图。 - 初始化LoadWnd:存储所有英雄表现数据,初始化所有组件,播放声音。

加载地图

制定协议:加载状况(C To S,房间号,进度百分比),广播加载数据到其他服务端(百分比List) - ResSvc异步加载地图:使用C#的AsyncOperation进行异步加载,并记录异步加载进度。 - BattleSyc拿进度:(需要每一帧都调用一次)将方法传入委托,在加载的过程中每帧进行Invoke,拿到异步加载的进度,并转换成int,每帧进度发生变化就发送给服务器 - 每一帧都要调用,则需要将一个Action将拿进度函数包裹起来,放到Update里面运行 - 服务端处理进度消息:拿到房间人数的消息后(避免频繁发送),广播一次进度 - 客户端接受广播消息:LoadWnd提供一个刷新函数进行刷新 - 加载完毕->PlayWnd: 放到BattleSys里进行控制,加载完后关闭加载界面,打开游戏界面。

切换战斗

定制消息:请求战斗开始(只需要传房间号),回复战斗开始(不需要传数据) - 客户端加载完毕:开启playwnd,发送请求战斗消息 - 服务端处理请求战斗消息:用一个数组存开始请求,当收到所有玩家请求,广播战斗开始消息,切换到战斗状态机。 - 客户端接受广播战斗开始:关闭LoadWnd,播放游戏音乐(此时场景已经异步加载好了)。

四 帧同步战斗部分(核心)

4.1 模拟服务器战斗GMBattle

防止每次都要联网,跟服务器和其他客户端进行匹配联机的复杂操作,先提供一个模拟服务器,直接进行咱都开发。 创建系统后记得在GameRoot中初始化单例 - LogWnd提供按钮操作:关闭当前窗口,实际调取GmSystem的服务。 - GMSystem战斗模拟: 协程分帧加载(资源加载->等待0.5s->战斗开始),资源加载和战斗开始都是客户端模拟new一个假的服务端消息,发给对应函数进行实现的。 - 简化场景导入:开发过程没有必要使用太过复杂的场景,用个白盒就行了。

4.2 战斗管理器FightMgr

是战斗系统逻辑的一部分,用来单独管理一场战斗中的所有资源(防御塔、角色、小兵、地图数据) - battleSys初始化战斗:加载FightMgr,battleSource,并初始化配置(地图数据) - 初始化FightMgr

帧同步战斗架构分析

  • 帧逻辑同步:15s,运动卡顿
  • 逻辑与显示分离
  • 表现层:继承自Monobehaviour,ViewUnit为显示单元
  • 逻辑帧:逻辑运算继承自接口ILogic,LogicUnit为逻辑单元
  • 逻辑单元又分为:主逻辑单元辅助逻辑单元

定点数 与 确定性物理库

这个单独放到其他文章讲

4.3 碰撞环境初始化

先导入定点数库与确定性物理库。 - 设置地图数据MapRoot:和地图中的资源绑定一下。 - 初始化碰撞环境:和Unity自带的组件不同,自己的物理库需要先初始化一套碰撞环境,用来收集所有碰撞的配置。 - 扫描并初始化碰撞配置:正常的Unity配置初始化应该会进行一次全量的实体碰撞扫描。由于我们做了单独的物理库,所以只需要扫描我们自己设置的目录下的碰撞体组件并生成配置就行。将对应的大小、类型、轴拷贝过去就行。

4.4 逻辑层抽象

不能使用Monobehaviour来开发游戏逻辑,使用单独的接口ILogic来控制生命周期。 - 基础逻辑单位抽象类LogicUnit:继承自ILogic,实现抽象方法,并设置关键属性:逻辑位置、逻辑方向、逻辑速度以及基础速度(方便速度复原) - 主要逻辑单位抽象类MainLogicUnit:主要实现英雄、小兵、塔都主要元素的控制,实现初始化、更新、反初始化逻辑,设置名字字段。并在构造阶段初始化配置。(包含一定的逻辑,但是不允许初始化)

属性设置 - UnitCfg增加核心属性 :用于模拟属性配置 - UnitStateEnum对象状态:用于判断存活还是死亡。 - UnitTypeEnum对象状态:英雄、小兵、塔。 - UnitTeamEnum对象状态:蓝队、红队、中立(野怪)。 - LogicUnitData逻辑单位数据:包含了队伍类型、位置、配置 - 主要逻辑单位MainLogicUnit根据配置初始化:拿到配置,进行初始化名字、逻辑数据、状态和类型。

分支处理(抽象的一部分) - 主要逻辑单位技能处理MainLogicSkill:是MainLogicUnit的一个partial。在原来的类型基础上,还增加了对技能的处理。实现初始化、更新、反初始化逻辑 - 主要逻辑单位技能处理MainLogicMove:是MainLogicUnit的一个partial。在原来的类型基础上,还增加了对移动的处理。实现初始化、更新、反初始化逻辑 - 主要逻辑单位攻击属性MainLogicAttrs:是MainLogicUnit的一个partial。定义属性如HP,防御力Def等。目前只需要初始化资产并通过配置表进行设置属性就行。 - 初始化逻辑更新:将其他分支处理的生命周期函数都注册在逻辑帧生命周期中。

数据传输过程:配置->逻辑数据单元获取配置->单元逻辑初始化逻辑数据单元,实现数据调用。 - 修改单元数据UnitCfg:先在配置新增化碰撞体配置,并修改单元配置数据获取函数(血量等属性,以及碰撞体设置)

4.5 创建逻辑英雄类Hero

具体的英雄类,就需要实现具体的实例化了。英雄类本身就有很多特性需要添加 - 英雄数据类HeroData:继承自LogicUnitData,构造时传入同时交给父类构造。有这个英雄独有的数据。 - 构造英雄类Hero:首先继承自主要逻辑单元,只需要从配置拿到该类的专有数据(heroID,posid,userName)以及单元类型(英雄)、单元名字(配置+英雄名字组合而成)。 - 英雄类Hero实例化InitHero:注意,这个逻辑是在战斗管理类FightMgr下控制。先创建个Hero列表存英雄逻辑实体 - 基础设置:遍历列表,同时从列表里拿到并设置位置i、英雄id和名字 - 分队逻辑:遍历同时,将英雄列表对半分,设置不同枚举。并设置出生点,然后再实例化英雄类Hero - 执行Hero的Init方法,并放到Hero列表里。 - 英雄逻辑帧Tick调用:先说明,所有的逻辑帧都是通过服务器传消息才调用用的,英雄的逻辑帧调用则是再逻辑帧的调用下遍历一遍英雄列表,执行LogicTick实现的。

4.6 逻辑移动开发MainLogicMove

与定点数物理库碰撞逻辑高度相关,移动速度也和帧率有关,所以: - 设置帧率:去Protocol通用库那边设置一个逻辑帧间隔时间=0.066f(单位s); - 基本定义:UI输入方向(也可以说是输入操作,就是帧同步传的东西),先定义自己的碰撞体,并用一个列表存储所有环境碰撞体。 - 初始化移动InitMove:获取出生位置,基础速度,逻辑速度,获得环境碰撞体(实际上是从物理库里面拿的,有点想不通碰撞逻辑为啥不封装在物理库里),此外,需要先初始化自己的碰撞体,并传入位置。 - 逻辑帧TickMove:先拿到移动方向, - 位置每逻辑帧变化== 方向 * 移动速度 * 逻辑帧间隔时间。 - 同时获取定点数碰撞系统返回的矫正方向adj。 - 逻辑方向如果不等于移动方向,则赋值修改成移动方向 - 逻辑方向不为0,就纠正逻辑位置,避免穿插到环境碰撞体中。 - 将碰撞体位置设置成逻辑帧计算位置。

4.7 定义操作协议(帧同步关键数据)

现在暂时先模拟一下传输过程,单局开发阶段就先不联网(太麻烦了) 消息定制:发送消息SenOpKey(房间号,OpKey),通知OpKey(帧号,List存多个操作数据) 单独建个新类OpKey单独存操作:目前只需要OpIndex操作号,KeyType枚举,以及SkillKey和MoveKey。 - 注意List存操作:并不是说是存十个人对应的指令,而是存该一帧收到的所有指令消息。通过OpIndex来分辨是谁发的消息。 这样做的是保证操作顺序 操作消息OpKey定制:移动消息(keyID,操作方向--x,z),技能消息(skillID,x,z)。 操作码:注意这两个指令是同步的关键,设置成100和101给其他的ID留下足够的预留空间。

4.8 客户端操作数据分发(逻辑帧的调用方式)

逻辑建议倒着看。。。先从最下层开始写了 - 逻辑单元Unit操作数据分发:逻辑单元收到FightMgr传来的消息后,判断None/Skil/Move, - 移动:如果是Move,就先将消息的x,z(原来是long)转为定点数,再转化为定点Vector,传给Move部分的InputMoveKey,实现移动 - 客户端FightMgr操作数据分配:战斗管理类FightMgr收到服务器发送的操作消息列表后,遍历消息列表,拿到对应英雄列表ID的英雄逻辑单元,进行操作数据分发 - 客户端战斗系统BattleSys分发数据(非常重要): - 将消息中的列表传给FightMgr进行数据分发。 - 更重要的是:收到消息才需要调用逻辑帧,这是帧同步最关键的一环 - 此外,设置一个战斗开始标志,开始了才调用逻辑帧。

4.9 转换UI操作数据

  • PlayWnd接受键盘输入:在Update里获取横轴、纵轴的输入输出。更新上一次输入
    • 优化:可以记录上一次的输入值,如果不变就不用单独发次消息。
    • 将vector传给轮盘输入,再次判定。
  • PlayWnd接受轮盘输入:由于地图斜了45度(?)所以先绕y转45度,注意轮盘也需要记录一次历史输入
  • 转换输入为定点数逻辑操作移动数据:定点数转换,然后调用BattleSys的发送移动函数。
  • BattleSys发送移动函数SendMoveKey:房间号,操作指令(指令id=selfIndex即房间位置,移动类型,移动消息)->移动消息(方向x,z,key的ID)。注意,key的ID每调用一次在客户端上是需要自增的

4.10 模拟服务器操作数据(帧同步服务器操作)

核心:使用FixedUpdate来模拟定时帧。 由于单局开发阶段联网还是太麻烦,因此决定用FixedUpdate模拟服务器来开发单局流程。为了避免大量改动,我们直接在网络系统NetSvc收发消息处做一个拦截。 当然,这部分就是服务器要做的事,可以重点关注一下 - GMsystem设置isActive本地单局开启:用于拦截服务器功能 - GMSystem设置模拟服务器功能: - 基础属性设置:帧号、指令列表 - SimulateServerRcvMsg:负责模拟服务器接受分发消息,比如处理SndOpkey - 更新指令UpdateOpKey:将OpKey加入到指令列表中。 - 服务器FixedUpdate逻辑帧模拟: - 帧号递增, - 然后广播一次指令消息,将帧号和操作指令列表装到广播操作消息中,清理当前模拟服务器缓存操作指令列表。 - 然后再将广播操作消息加入到客户端的消息队列中(客户端在Update里通过消息队列取消息进行渲染帧分帧消息分发) - NetSvc网络系统拦截SendMsg:直接调用GMsystem的模拟功能,并且默认成功触发回调。 - 客户端Update分发模拟:由于原来客户端通过判断会话存在,模拟分帧从队列取出消息进行分发。所以我们还要新增一个逻辑判断GMSystem的isActive。 - 客户端消息分发处理HandoutMsg:客户端新增对NtfMessage的处理,对接上前面的客户端逻辑帧触发。收到一次广播即调用一次Tick。

Pasted image 20250604202723

五 移动预测表现与技能释放

5.1 表现单元抽象类ViewUnit

和逻辑单元功能相对应,但是由Monobehaviour控制生命周期。

角色模型管理: - 根节点只控制移动, - 模型子节点就负责控制旋转(分开是为了避免和其他节点的内容一起旋转了,代码里设置同步位置或旋转再具体写相关的逻辑) - 当然,没设置就自动设置成根节点了(小兵、建筑之类的东西) - 其他的结点控制特效、ui等。

绑定逻辑单元:渲染帧一定是根据逻辑帧运行的,因此Init时必须绑定逻辑单元并根据其数据进行基础配置。 表现层初始化时机:当然也是在逻辑层Init时顺便初始化的

其他具体功能就不单独讲了,写法和逻辑层基本相似甚至更少。

MainViewUnit

  • 攻速/移速动画变化
  • 技能动画播放
  • 血条信息显示
  • 小地图显示

HeroView

  • 技能释放显示
  • 旁白

5.2 ☆运动预测算法原理(位移同步--外插值)

  • 预测帧数限制:预测太多,可能会导致偏差过大,需要限制预测帧数。
  • 逻辑位置变更检测:将逻辑位置的Set用属性来封装,用一个bool值isPosChanged记录逻辑位置变更。而Set时就同时将这个isPosChanged设置为true。
  • 开始预测--检测逻辑帧变更:检测逻辑位置变化变量为true(代表逻辑帧推数据了)。先将预测目标位置设置为逻辑帧位置,再将逻辑帧检测变量设置为false避免重复修改,清空已预测数量
  • 预测方案--位置预测,解决运动不连续:预测位置 = 逻辑速度 * 逻辑方向
  • 运动位置平滑(插值),解决运动突变:lerp插值就对了,这样会减轻位置突变的问题。
  • 实现转向平滑(插值),解决旋转突变:提供一个角度倍增器字段(根据角度动态决定基础速度,角大速度大),和加速度字段(叠加到基础上,一个固定的变化速度)。 MMO的处理:针对自己,直接根据UI操作进行位置的预测和平滑,后续消息抵达再做矫正。

  • UI输入覆盖物理碰撞修正方向:由于碰撞会修正速度,但是我们需要靠墙时保证同步上UI的输入,所以我们需要根据UI操作方向在表现层对速度进行重新修正。不使用物理引擎提供的速度方向,这个我们直接在英雄层重载函数即可

5.3 动画控制、摄像机控制(3C)

  • 基础值:
    • 基础移动速度:用逻辑基础速度定点数转化成浮点数的值
  • 移动动画切换(通过名字播放):抽象层写一个播放PlayAni方法,交给底层来实现。
    • 行走:根据速度和基础速度,设置播放速率。并且过渡时间也要相应的缩减
  • 摄像机控制: 在战斗开始是,就通过当前客户端的PosIndex设置跟随对应的英雄角色。此外,还要提前设置地图摄像机,并且在FightMgr.Update里实时更新。(BattleSys和FightMgr单独的资源都是管理当前客户端的)

5.4 UI操作控制

对每个按键和UI的操作,提供一个匿名方法进行计算位移和调用函数(比如发送移动消息) 并注册到对应的(点击、抬起、拖拽)事件 中去。

注意:这些事件的调用都在PElistener管理器的对应默认UI调用函数(如OnDrag、OnPointerClick)中Invoke。以此来辨别按键类型,间接调用之前注册在这些事件的真正执行方法(如操作滑轮)。 - 滑轮初始值常量配置:设置到客户端的Constant中。

5.5 技能配置(ResSvc)与初始化(PlayWnd)与UI控件

首先技能是一套,那就是一个技能ID数组来存到英雄里面方便调用(一个角色四个技能)。 单个技能配置:写一个基本的结构体(ID,图标名称,动画名称) 多个技能的缓存:static数组 用一个函数去获取技能配置。

技能这块可以做的相当复杂,因此最好单独分离到一个模块去。这里直接在PlayWnd分个Partial做技能

技能图标Item:游戏UI上的技能图标可以单独做个Item组件,方便复用。 技能信息初始化: - 拿到客户端对应的英雄,再拿到英雄对应的技能配置,将配置和ID索引依次传入之前的技能Item的初始化函数中。 - 然后先禁用1、2、3技能。 - 最后绑定一下Item的组件就行。

技能Item初始化: - 缓存ID和配置 - 设置按键按下和松开以及对应的回调函数lambda实现。 - 范围显示以及连击特效播放: 按键避免持续点击普攻,用协程来控制CD。实现单击短暂持续特效,持续期间再次点击重新播放的功能。 - 为了减少GC,最好先缓存一个Couroutine,避免反复创建增大开销。 - 当按键抬起时,打开范围特效,协程等待0.5s后关闭范围显示。 - 如果期间再次按键抬起,此时协程又已经存在,则立刻停止协程并关闭范围特效再重新播放连续点击判定

如果非普通攻击: - 获取按键图标,禁用禁用图标。 - 如果按下:显示拖拽滑轮,根据滑轮位置设置英雄范围。 否则发送技能普攻指令: - 发送技能指令:按下按键后,传入对应的技能ID和参数(没有就是Vector2.zero),发送技能指令

技能配置完善

技能配置: - skillID - skillIcon技能图标 - aniName动画资源 - releaseMode释放方式枚举 - targetCfg目标查找配置 - CD冷却 - SpellTime前摇 - skillTime技能全长(包含前摇、后摇) - damage伤害 - buffIDArr【】附加buff - 音效(开始、成功、击中) 释放方式枚举: - 点击 - 位置施放 - 方向施放

目标查找配置

  • 选择规则:单体、多体,全图······
  • 目标队伍:动态、友军、敌人
  • 目标单元类型数组(多选):英雄、小兵、塔等
  • 辅助参数
    • 目标距离范围:
    • 移动攻击搜索距离: Pasted image 20250608175731

5.6 技能范围显示(SkillItem)与角色技能引导

  • SkillItem获取HeroView,拿到技能配置然后将显示范围函数注册到点击事件上
  • 角色技能引导: 通过滑轮事件拿到偏移vector,传入vector到HeroView对应处理中
    • Position位置:根据vector调整位移
    • Direction方向: 根据vector调整旋转方向
    • 其他:关闭技能引导。
  • 角色技能取消:拖拽的更远,并且抬起就取消释放(取决于全局配置)
  • 角色技能释放:拖拽的较近,抬起就释放(根据不同的释放方式有不同的技能释放处理),关闭引导。

六 技能系统

技能计算:技能的计算都是用定点数计算的 - 发送消息与接受消息:和其他消息一样,不过具体处理写在逻辑单元的Skill模块

技能模块是个非常复杂的东西,我们单独建个文件夹Skill来管理技能代码。 - Skill技能构造:通过ID和传入的持有者逻辑单元,进行初始化 - 逻辑单元构造技能:将基础配置的技能ID列表以此进行Skill技能构造。 普攻速率:分为基础速率(仅初始化:数值为1秒/技能总时长)和实际速率。实际表现应该体现在技能时间上 - 普攻速率调整:一些技能对普攻速率也有影响,当我们改变实际速率时,用属性修改Set拿到普攻技能,对其进行技能前摇时间和技能总时间上的调整。

技能系统

第一步:查找目标 Pasted image 20250608180545

6.1 查找计算规则CalRule

做成一个静态类,不需要实例化。 - 存储所有逻辑实体类型: 将英雄、小兵、塔等实体都到这里面。并在战斗开始Init时初始化。 - 查找单一目标:输入查找者、查找配置、位置,返回目标。 - 过滤器:过滤死亡状态实体(逆序遍历列表删除,防止迭代器失效问题) - - 查找多个目标。 工具方法: - 队伍筛选: - 判断当前配置是否包含某种实体: - 查找单一目标 - 查找最近目标(当前逻辑单元,目标队伍列表,范围):遍历team并计算长度。

6.2 目标技能

  • 技能目标方向计算:虽然帧同步会传操作方向,但是对于目标技能我们还是需要根据目标单独计算一遍方向的。
  • 技能前摇释放:设置技能状态为前摇、,需要调用到视图层的一些东西(播放前摇音效)
    • 表现层的插值目标方向设置为技能目标方向
    • 此外,需要在表现层假装修改移动输入方向为0,英雄释放技能的时候进行位移的打断
  • 动画攻速调整与播放:表现层获取逻辑层的攻速,然后传到动画里去播放。

6.3 子弹配置

子弹类型

  • UI指定位置
  • UI指定方向
  • 当前技能目标(跟踪)
  • 依靠buff搜索

子弹配置

  • 子弹类型
  • 子弹名称
  • 资源路径
  • 大小
  • 高度
  • 速度
  • 偏移(释放位置需要有变化)
  • 延迟释放时间
  • 是否能被阻挡(比如虞姬1,墨子2)
  • 目标配置(设置能影响的物体,比如敌人)
  • 子弹持续时间(不包含延迟时间)

技能配置补充

需要子弹的技能,添加上弹道配置。

6.4 技能执行

  • 如果是弹道技能,发射子弹(后面再做)
  • 如果非弹道,则触发命中目标函数。 命中目标
  • 播放音效:注意是让目标播放
  • 直接伤害:伤害不为0,让目标hp减少(直接调用对方的受伤函数,)。
    • 死亡状态:目标血量过低切换到死亡状态,移动向量设置为0,播放死亡动画。
    • 死亡和受伤回调:逻辑单元属性提供Action事件注册(比如后面挂载buff时使用)
  • 附加buff:后面再做

6.5 血条以及伤害数据显示

血量Item抽象类ItemHP

  • 初始化:判断客户端的队伍,判断这个血量是否跟自己一队的。填满UI的滑动条,设置根节点,设置血量。
  • 血量更新:传入新值刷新进度显示,为0就关闭血条(死了),否则就打开。更新ui滑动条
  • 图标状态变换SetStateInfo:传入状态、是否显示
  • Update更新:根据高度计算屏幕变化比率,计算屏幕位置:将挂载点从世界空间转换到屏幕空间坐标。最后位置就等于屏幕坐标* 屏幕变化比率了。 (这里我不知道为什么要计算变化比率,屏幕空间不就直接是结果了吗,改了反而有问题了)。
  • 状态枚举:沉默、击飞、眩晕、无敌、禁锢······

小兵血量ItemHPSodier

很简单 - 初始化:根据isFriend选择不同的血条贴图Sprite - 设置状态:传入状态枚举,根据状态枚举更换图标。

英雄血条ItemHPHero

其他的都跟小兵血量一样,只是多了个血量分割竖条显示(根据hp计算打开的数量,layoutGroup自动控制排列)。

塔的血条ItemHPHero

这个就更简单了,只用判断isFriend就行。

血条管理窗口HPWnd:

血条管理,对象字典: - 增加组件信息: - 如果在字典里已经有了就不添加。 - 没有的话判断单位类型,拿到不同类型的血条prefab资源路径,加载后设置其根节点并添加到字典 - 表现单元引用:先绑定各个角色的HpRoot(作为血量显示的位置),在表现实体初始化Init时,在HpWnd添加对应的Item组件。 跳字管理、对象池

6.6 伤害跳字

跳字类型JumpTypeEnum

  • 技能伤害
  • buff伤害
  • 治愈
  • 减速。

跳字类

属性: - rest - 动画机 - 文字 - 字体大小最大值。 - 字体大小最小值 - 最大字的值(注意和最大字体大小值区分,一个是字体一个是数值) - 颜色 - 技能 - buff - 治愈 - 减速

跳字缓存池

漂字缓存池: 使用C#队列(vector + push)来管理漂字物体。

  • 创建跳字
    • 加载跳字物体并实例化
    • 设置跳字名字 = 跳字+id(设置为属性自增)
    • 设置父节点和初始大小。
    • 拿到跳字类。将其返回到queue缓存池中pull进去。
  • 新增跳字:创建跳字JumpNum入队列,。 构造:根据count创建跳字入队列

  • 拿到并视同跳字PopOne:出队列deque。

    • 如果队列没有跳字缓存,新建一个跳字加入到队列中再拿。

漂字类JumpNum

  • 指定漂字缓存池
  • 需要更新的漂字信息JumpUpdateInfo
    • 漂字的数值
    • 位置
    • 漂字类型(治愈、伤害等)
    • 漂字动画类型(左,中,右)
  • show展示漂字:传入JumpUpdateInfo
    • 字体大小钳制:限制到大小范围内进行插值
    • 字体数值有个上限值(注意区分)
    • 根据类型选择不同的跳字方案:文本+颜色
    • 根据动画位置枚举选择不同的动画状态:左、中、右
    • 最后用协程延迟回收(等待漂字动画播完),加入队列。
  • 延迟回收:协程等待0.75s,设置Empty动画机状态,放到缓存池中

血量变更检测

在MainLogicAttr逻辑单元新增委托事件: - 受伤事件调用:在受伤GetDamageBySkill时,new一个JumpUpdateInfo,根据伤害提供跳字信息。 - 判断受伤者和释放者:如果受伤者和释放者是自己,给info赋值。 (这里没太看懂,既然是通过target受伤,为什么还要多判定一次?) - 调用受伤回调:传入血量和info,触发事件 在表现层注册: - 先在表现层再次拿到玩家位置,并传入jui - 再调用HpWnd的设置血量函数(传入jui):血条扣血并且跳字。

七 技能系统BUFF系统与配置

6.1 技能系统逻辑

接上文释放技能计算伤害: - 新增BUFF - 调用spellAfter前摇结束的逻辑(即后摇处理): - 播放释放成功音效, - 如果不是普攻则进入技能CD:仅表现层显示UI用,实际上是使用倒计时定时器。 - 技能释放成功调用回调 - 恢复UI输入。

6.2 MonoTimer表现层计时器

一个可以设置循环次数的计时器,提供大量回调接口,能提供倒计时的功能。

如何使用

自己拿到时间差,传入TickTimer(dela) 但是值得注意的一点是deltaTime是以秒为单位,而为了方便设定时间,游戏中以毫秒为单位,所以需要乘以1000

timer.TickTimer(Time.deltaTime * 1000);
有了这玩意,就方便做 倒计时、循环计时了

定义

  • isActive
  • 单次回调函数Action<int> cbAction;
  • 循环时间间隔(intervalTime)
  • 循环次数(loopCount)
  • 间隔回调函数Action<int> prgAction;
  • 整体结束回调函数Action<int> endAction;
  • 延迟时间:delayTime;
  • 整个时间:prgAllTime; 计时器:
  • 延迟计时器:delayCounter;
  • 回调计时器:cbCounter:
  • 循环次数计数器:loopCounter;
  • 整体进度计时器:prgCounter 计时进度比率:
  • 循环进度prgLoopRate = 0;
  • 整体进度prgAllRate = 0;

构造参数: - 传入参数 - 激活 - 整个时间 = 延迟时间 + loop次数 * 时间间隔

TickTimer(delta) 有延迟时间: - 正常截断(调用Tick)。 - 延迟阶段 ,需要单独计算进度比率和总比率 没有延迟时间直接tick。

Tick(delta): 原理很简单,其实就是拿到tick的间隔,然后加到原来的时间上。 循环就-=intervaltime,循环计数器+1。

GameRoot全局管理计时器

为了避免计时器乱飞必须使用这个全局管理器来管理全局视图层的计时器。 - 定义两个列表,存储全局管理计时器 - AddMonoTimer - Update:将temp加入到总管理列表里,通过isActive判断执行还是移除。 为什么定义两个?一个temp,一个总的,为了不影响当前计时器的迭代Tick,我们需要先将MonoTimer加入到temp列表里,下一帧再开始计时。

WindowRoot 添加计时器

提供一个快速创建定时器,并添加到GameRoot的公用方法。

6.3 LogicTimer 逻辑计时器

逻辑计时器其实简单,只需要延迟时间和持续时间就行了。 延迟+无限循环计时器:loopTime !=0 - 不用算进度,因为是无限的 - 先短暂计算延迟时间。 - 后续将延迟delayTime时间赋值为loopTime,实现无限循环。 - 每次循环开始时,调用一次 - 只需要注意使用**逻辑帧时间:66ms。 仅延迟一次计时器:loopTime == 0 - 延迟计算一次后,判断loopTime = 0。直接返回清空关闭计时器。

逻辑单元Skill 注册、执行定时器。

  • 存储,使用列表存储逻辑计时器。
  • 使用TickSkill 轮询计时器:通过isActive判断执行还是移除

6.4 延时技能逻辑

技能生效方法注册到逻辑定时器,仅延时。 - 技能生效: - 先查找目标,如果目标依然存在 - 执行技能(计算伤害->添加buff->技能后瑶逻辑)

6.5 辅助逻辑单位抽象类SubLogicUnit(用于Buff、Bullet)

辅助逻辑单抽是继承自LogicUnit的,但他不单独存在,一定是派生自主逻辑单元(英雄、小兵、塔等)和技能的。 所以:必须有 - MainLogicUnit字段 - Skill字段 此外就是跟状态和生命周期有关 - 如SubUnitState状态 - 延迟时间计数delayTime - 辅助单元状态delayCounter

辅助逻辑五状态--枚举SubUnitState

  • None
  • Delay 延迟生效
  • Start 开始
  • Tick 逻辑更新
  • End 结束

生命周期

  • 构造:传入主逻辑单元和技能
  • LogicInit:根据延迟时间,判定是Start还是Delay
  • LogicTick更新判断Delay、End状态,执行不同的逻辑(后面子类再加Start、Tick逻辑)
    • Delay处理:每次delaytime-=逻辑间隔(66ms),延时结束切换到Start
    • End处理:调用End内容,切换到None。
    • 没判断其他状态是因为需要子类,这里只做通用的状态切换判断

6.6 Buff配置☆☆☆

  • buff类型枚举:比如单体移速修改、沉默、群体移速修改、移动攻击、标记等效果。
  • 附着buff类型枚举:施法人、目标、独立(指定位置)、子弹(弹道产生指定位置)
  • 静态位置类型枚举:给不需要目标的buff用,
    • 比如技能所属释放者的位置buff所属技能锁定目标的位置子弹命中目标的位置UI输入的位置
  • 目标配置:仅需要目标的buff使用。
  • buff延迟:延迟生效
  • buff间隔Interval:用于持续性伤害
  • buffDuration:buff总持续时间(不包含Delay)
    • 0:生效1次
    • -1:永久生效
  • 其他细节(根据需求来)
    • buff释放Audio
    • buff特效
    • buff命中audio。

ReSBuffConfigs

具体的buff配置,按道理这里应该做成表格数据。 但不同的类可能会有一些不同的属性,因此我们对应一个buff需要单独建立一个新的类来比如MoveSpeedBuff继承自BuffCfg。 - MoveSpeedBuff_Single单体移速类:单独加个特有参数:amount,

6.8 Buff逻辑基类:继承自SubLogicUnit

基础参数: - Buff附着单位 - buffID - 参数args 时间参数: - 持续时间 - 总buff计时: - 逻辑帧计时:仅当逻辑帧计时器计算(用于间隔触发)。 Buff配置:缓存的buff 群体作用目标列表targetList

函数: buff构造: - mainLogicUnit:buff源头 - mainlogicUnit:当前buff持有者(附着单位) - Skill:技能源头 - id: - 参数 LogicInit初始化: - 注意了,我们并不需要传buff具体配置(时间、参数等信息)来生成buff。而是在初始化阶段直接根据SkillID拿到Buff配置信息,并赋值。 - 此外,基类需要在初始化后再运行。

重点:LogicTick() Buff逻辑帧更新处理

SWITCH做分支 Start状态处理: - 如果大于0或者为-1,代表需要持续的Tick,切换到Tick - 否则为0,立刻处理,直接结束到End。 - 否则buffid设置的不对 Tick状态: - 间隔触发:buff生效间隔 > 0, 逻辑帧计时tickCount每帧加一个逻辑帧时长 - 如果tickCount > buff配置的buff间隔生效参数interval。 - 减去interval,并触发一次Tick。 - 总计时+=逻辑帧时长 - BUFF持续结束:如果总计时 > buff时长,并且不为-1(永久) - 结束

6.7 利用Buff制作技能

以亚瑟一技能为例。 - buff技能配置:先传入BuffID数组,描述该技能需要的buff。 - 亚瑟1技能:加速buff--10110,普攻强化buff--10111

buff对应的逻辑处理类:

继承自BUFF,比如MoveSpeedBuff_Single加速类 内部值:**** - 这个类里用的是speedOffset速度偏移。

构造:直接运用父类的。 LogicInit

这里有一个非值得注意的点:父类我们用的是基类来获取的子类配置(引用传参),不会发生类型截断。我们只需要在将其as转换回来即可。

  • 将父类的buffcfg转成对应的配置movespeedCfg。
  • 通过配置的amount值角色的基础移速,计算speedOffset

主逻辑单元修改: - 修改逻辑单元移速方法:这里先给逻辑单元提供一个修改移速的方法, - 原速度+加上传入的value。 - 减速跳字。 - 执行减速回调 - 提供一个减速回调Action,传入跳字信息


Start 不是LogicStart哈,放Tick里的。 - 调用逻辑单元的修改移速方法,传入+speedoffset

end 根据start的执行,做回溯,buff结束应当取消影响 不是LogicStart哈,放Tick里的。 - 调用逻辑单元的修改移速方法,传入-speedoffset(注意是负的

6.8 非目标技能类实现

先给逻辑单元SKILL部分提供一个创建buff功能。 提供施法者、技能、id、参数信息

分支条件:cfg.target = null无目标 前摇:老规矩,SkillSpellStart(skillArgs)直接写上去就行 。

【非目标弹道技能】:先放着,后面搞

【非目标技能释放整体流程】:这一块可以单独放一起 - 如果有子弹配置:使用非目标弹道技能 - 【附加buff到==施法者==】:这也是一个函数 - 先判断配置的buff是否为空 - 遍历buff配置数组 - id为0,报错 - 根据id拿到buffCfg - 判定buff附着类型为施法者或者区域释放 - 是的话调用技能。 - 后摇动作

技能释放: 前摇时间为0: - 直接使用【非目标技能释放整体流程】 前摇时间不为0: - 添加逻辑延迟计时器:计时 - 计时结束回调【非目标技能释放整体流程】

创建一个真正的buff实例运行类

ResSvc全局函数: 在资源Svc里提供一个真正的buff实例运行创建类CreateBuff。 原理:先用ID拿到配置,再根据配置buff的的类型枚举获取对应的buff实例类。比如上文写的MoveSpeedBuff_Single单体加速类。

逻辑单元处理: 管理:先写一个了列表存储buff

在mainLogicSkillBuff里写一个CreateSkillBuff,多一个目标参数 - 将目标设置为自己this - 根据资源系统的CreateBuff创建真正的buff。 - 创建时触发其LogicInit: - 将buff存储到自己的buff列表中。

这样我们只需要用==target.CreateSkillBuff()==就可以让目标产生buff了。

执行Buff

和逻辑计时器一样,都在技能部分的TickSkill倒序遍历轮询Tick逻辑 - 状态为State

6.9 BUFF制作

  • 沉默buff:仅修改状态计数