关卡开发哲学

我从20年开始在网易负责关卡开发的工作,负责关卡框架开发与效果调优,维护关卡技能、物理和特效等,帮助组员完成开发。下面以我的经验为例介绍我的开发经验和成果。
以下许多内容来自我的《关卡开发模式分享》ppt

关卡的基本组成部分
  • World-Level-Entity 的对象架构
  • 场景表现实体 与 逻辑实体
  • 渲染表现,规则设定,天气系统
  • 关卡整体逻辑 与 个体逻辑

关卡框架
全局数据实体
  • 副本框架(独立场景)
  • 大世界框架(开放场景)
  • 场景切换流程(加载与清理)
  • 开始、进行、结算与rpc
数据实体的好处是独立且功能丰富,把关卡整体当一个Entity看,有自己的生命周期,也有数据同步和状态恢复等行为逻辑,和关卡中的Entity、Player不相耦合

个体Component
个体数据
  • 关卡组件(各关卡都有自己或独有或复用的数据)
  • 系统组件(各系统)
  • 状态组件(用于恢复状态)
主要功能
  1. rpc信息通知+部分数据同步
  2. 个体UI显示(如姓名牌,有数据变化)
  3. 特效/材质表现(如入侵、高亮状态)
  4. 第一方/第三方行为区分
我对关卡框架,做了比较多的改进、重构、设计与推广等工作

关卡开发
  • 功能白模
  • property同步与rpc
  • 状态恢复与表现一致性(断线重连,中途加入,加速过渡)
  • 解耦合与事件传递(需要有Mgr和事件分发)
  • 配置支持与复用性(可扩展性)

关卡工作流
  • Prefab管线
  • 动态组件流
  • 关卡蓝图
  • 可视化配置
  • 资源替换

独立的UI
  • UI框架解耦
  • 总比分(主UI)
  • 系统UI挂接位置
  • 姓名牌
  • 指引UI
  • 切场景UI处理
旧版框架中,主UI承担了DungeonMgr的工作,所有子UI的控制和信息分发都走主UI,这样会有很多不相关的数据和系统会相互产生耦合,甚至连管理权也在主UI(这让弹窗、血条、金库实体等都需要从主UI这里获取数据)
新版框架中,主UI就是一个关卡UI框架结构,不控制数据,子UI数据来自于其监听的数据源(如DataEntity、Player),各模块区分开、互不影响。管理关卡的是DataEntity,管理UI的是UIMgr
逻辑与显示分离、UI可以先打开再等到数据刷新、不会因为数据先后到达顺序对UI产生影响,这样也更支持需求灵活变动
计分板只负责计分板的事,与入侵、任务、信息提示等模块区分开,互不影响(不会因某个模块出问题导致其他模块也用不了)
UI先创建的好处是,如果有UI需要挂接到该UI,也不会因该UI未创建而挂接不了(原先会因为数据后到而未完成创建,从而阻塞前面的任务,或者因依赖关系而提前创建,打乱了生命周期)
这样的开发模式接近于D2端游,也有助于支持后续需求的灵活变动,某个模块的改动对其他模块影响会很小。
不会用主UI控制子UI的模式,而是两者分离开,主UI先创建(生命周期更长),数据驱动创建和更新子UI

系统库
  • 掉落系统
  • 任务系统(任务编辑器)
  • 引导系统
  • 交互系统
  • 结算系统
  • 奖励系统
  • 匹配房间
  • 对话/解说系统(关系数据库+storyline配置)

机制库
  • 传送门机制
  • 占领点机制
  • 抱球机制
  • 捡物资机制
  • 关卡技能机制
  • 传球机制
  • 点灯机制(关卡进度解锁)
  • 移动平台(keyframe物理,客户端真实物理模拟+服务端修正)
  • 变速带(范围标记)
  • 计时器
  • 奖励机制

工具管线
  1. 注册Entity类
  2. 配置表
  3. 编辑器
  4. Sunshine
  5. 演出系统(剧情编辑器)

关卡物理
  • 坠崖检测
  • 高度修正
  • 卡点检测与脱离
  • 物理模拟
  • 寻路与动态障碍

关卡特效
  • 延迟特效
  • 渐变特效
  • 缩放特效
  • 溶解特效
  • 屏幕特效
  • 连线特效
  • 叠加材质
  • 覆盖材质
  • 裁剪材质
  • 透视描边
  • 遮挡半透

关卡技能
  1. 交互技能
  2. 持球技能
  3. 变身技能
  4. 希望武器
  5. 扔球技能
  6. 持球攻击/连击
  7. 索敌与辅助瞄准
技能同步、恢复与中断

关卡优化
  • LOD
  • 预算系统(比如特效,我有很多东西想显示,但是我CPU和存储资源有限,那么我就要在预算内优先显示最重要的)
  • AOI
  • 分线,动态加载
  • 本地数据缓存(减少传输消耗)
  • 遮挡剔除
  • 自动下沉

关卡协作
  1. 关卡策划,技术策划,其他组策划
  2. UX,特效,建模,动作,场编
  3. 关卡程序,机制程序,服务端程序
  4. QA,分支跑测,调试机制,快速修改验证(修复)
比较重要的是责任意识,协作能力,纵观全局的能力

体验优化
  • 流畅度
  • 反馈感
  • 关卡设计
  • 具体到子功能的精细化处理
关卡设计就不得不提十大原则:

大型系统
  • 火车模拟(曲线算法,抖动处理)
  • 载具同步与物理(客户端模拟/服务端模拟/主客户端模拟权等)
  • 公共场景事件(WorldDataEntity/PEChainEntity/PublicEventEntity三级结构)
  • PVP玩法、PVPVE非对称玩法
  • 大型组队副本

上述部分并不完全,只是我所做的项目中,关卡开发方面主要涉及的部分

Entity框架
Entity,Components,Kind,Property,Event,Timer,Extensions,RPC
  • EntityMgr 与 EntityFactory
  • Space 与 Server::AsioArea
  • Model 与 asyncore

动态组件流实现
瓶颈:组件不能可视化操作、也不是动态的,都是程序写死的,不利于满足开发和策划调配的灵活性
原因:历史设计问题,引擎起初没有编辑器,组件要求在Entity创建前全部初始化,属性也在那个时候就注册好

解决:分三步
  1. 将组件转为可视化组件(通过Sunshine编辑器的反射功能,找到组件上的属性显示出来)
  2. 支持可视化组件的动态添加、删除(分析源码,抽出Add/DelComponent函数,并让编辑器也支持操作,并序列化写入ets)
  3. 支持动态组件的属性、rpc同步(目前通过两个预设属性components和properties实现,由静态属性转发动态组件的属性变化)
> 实际付出的努力不止于此,还需推动组件通用化、易用性等

结果:提出并落地了一套新的开发流程——动态组件流,有效解决策划与程序在制作时的灵活性、复用性,提高开发效率。并将实际效果反馈到引擎组,促使他们从根源解决问题。举例:显示UI组件、进度组件、交互组件、特效组件等

不可替代性:这个问题其实困扰了很多组很久,但由于引擎的限制,都没有太好的解决办法。我通过分析,想到一个绕开引擎限制的办法解决了该问题,使我们的组件像Unity的一样灵活,且支持与服务端同步通信


A*寻路

A-Star算法是一种静态路网中求解最短路最有效的直接搜索方法,也是许多其他问题的常用启发式算法。注意——是最有效的直接搜索算法,之后涌现了很多预处理算法(如ALT,CH,HL等等),在线查询效率是A*算法的数千甚至上万倍。

欧几里得/欧拉距离: 多维空间两点间的距离,即直线距离
曼哈顿距离:估计到目标格子之间的水平和垂直方格的数量和,即不走斜路

将地图用导航图表示:
  1. 基于单元的导航图
  2. 创建可见点导航图
  3. 创建导航网格NavMesh

以基于单元的导航图为例,执行A*算法

经过n点到达的估计代价 f(n)= 起始点代价 g(n) + 目标点估计代价 h(n)
open表:待考察的结点的优先级队列,代价从低到高(可以不排序,只找最小值)
closed表:已考查(预定路径)的结点列表

回答面试:将地图划分为导航网格,从起点开始计算周边各点,估计代价。
①一开始,取起始结点到closed,将其8个邻接点加入open ,估计代价f(n)= 起始点代价 g(n) + 目标点估计代价 h(n)
这里g是可以更新的,h是无视障碍物估算的

②将open表min结点取出,计算其周边结点。
  1. 如果不在两表之中,加入open表
  2. 如果在open表中,更新更低的代价值
  3. 如果在closed表中,又需要更新更低代价值,则移到open表,下次可取点重新计算
  4. 对于障碍物点,不加入,下次取结点就不会取到障碍物点
  5. 这些周边结点会记录自己是由哪一个结点Parent计算的,以便回溯得到寻路序列
将这个min结点加入closed表

③如此循环,具体过程可看图,事实上如果有障碍物的话A*会计算一些被挡路的结点(因为每次取min点),但最后还是能得到到达序列

对 A* 的改进
最佳优先搜索(BestFirstSearch)算法 其实属于启发式的搜索算法的一类。启发式搜索和前面的盲目搜索不同,盲目搜索是按照固定的模式进行搜索,没目的性、全盘搜索,而启发式搜索是在寻找路径的过程中,利用一些可以提计算出的有利信息作为估价值,程序通过估价值提供的信息进行更加智能化的。这种通过启发信息来指导整个计算搜索过程的算法,统称为启发式搜索算法。
一般来说,最常用的启发信息便是设置一个估价函数F(n),通过估价函数引导搜索。最佳优先搜索算法的估价函数是对游戏场景地图中起始点到目标点间的距离经过简单的估值计算得出一个估价值,该算法与Dijkstra搜索算法的同之处就是其在选择下一个节点的时候并不是取相邻节点中靠近起始节点的相反是取相邻节点中靠近目标节点的顶点,这一项改动大大提高了该算法搜索速度。

AOI

AOI 计算场景中的每一个对象 在其周围一定范围内的 可见对象集,并在频繁交互的场景中,实时更新可见对象集。
典型MMORPG有大量的强交互对象,对象状态的消息需 要实时广播,当对象数量较多时,就需要Area of Interest算法来决定需要通知的对象集合,以减少计算和封包数量。
相比于接近于LOD思想的 传统AOI,分层AOI 将感兴趣不同的对象划分到不同的层
传统的AOI只能单一的以对象之间的距离来判断相互是否可见,无法实现定制需求(不同的对象具有不同的可见性) 于是,分层AOI的设计应运而生。

经典AOI--2D场景

算法核心就是当 Avatar 发生 进入/离开/移动/状态更新 四类事件时,如何高效地得出被通知的对象集合
场景内的对象需要以某种数据结构组织起来用于计算广播列表,一种是十字链表,一种是九宫格划分。
十字链表
源于 sweep and prune 碰撞检测算法
九宫格法(GridBased)
九宫格内可以"分层"

传统AOI--AsioArea

AsioArea(PropertyNotify)
进入AOI时,该实体被创建(init_from_dict->LazyInit
离开AOI时,该实体被销毁(on_out_sight->Destroy
其他如on_update_position、on_update_direction、on_speed、on_prop_change详见接口

分层AOI

规则
  1. 同一层中的对象在AOI视野范围内,相互可见。
  2. 若两个对象在任意一层中满足可见,则两个对象可见。
数据结构
  1. 层编号ID
  2. 对象所在层列表 bitset
  3. 层相交 intersects,用于属性同步/范围查询
bitset 用于存储二进制数位,它就像一个 bool[] 一样,但是每个元素只占1bit(空间进一步优化)





comments powered by Disqus