(本文由近年随记整理而成)
数据对于游戏意义非凡。
游戏从实质上,可以看作是数据的集合。
玩游戏,就是在与服务器交换数据,最后改变这些数据存档。
一、数据划分
1、按所属划分
系统数据:系统本身的配置数据(如主线有哪些章、地图是怎样的、有什么NPC等)
个人数据:包括,账号数据(账号ID,token等)、角色基本数据(如,昵称、头像、角色等级经验等)、角色在各系统下产生的数据(如,拥有的道具资源、签到时间、Boss挑战次数,好友列表等)
服务器数据:由服务器生成和维护的数据(如,排行榜列表、公会列表等)、各系统由服务器托管的数据(活动开关时间等)。
2、按存储划分
服务器存储数据:重要存档。
本地持久化数据:记录在本地硬盘的数据,不保证安全。对于网游,常记录一些非重要存档(如红点今日是否展示过等)
临时数据(对象):某时刻临时生成,用完即释放的数据(如,奖励字符串转成的临时奖励对象、英雄升级预览时的临时英雄对象)
3、按动静分
静态数据:又可分为 直接静态数据(策划配置)和 间接静态数据(基于静态数据二次处理的数据 或 基于动态数据后查找的数据(如当前等级的配置))
动态数据:又可分为 直接动态数据(服务器存储,发给客户端的字段)和 间接动态数据(经过二次计算的,如推算的等级(只发经验时)、推算的战力(根据攻防血等计算))
4、按功能模块划分
各模块按数据层面的功能归纳,需要注意 父子模块、紧密相关的模块(如,公会和公会签到、英雄和佣兵、英雄和共鸣水晶(《剑与远征》中)等)。
还需注意 “数据概念的模块” 和 “显示概念的模块” 的区别,有时一个界面可能会杂糅引用多个数据模块(如在同一个背包中存放道具、装备、碎片、宠物等)。
二、客户端数据模型的生命周期
0_0、数据模型目录结构
按数据层的功能模块创建 XXModel(单例),自顶向下组合/聚合下层实体类,形成该功能模块的数据模型。
--XX(Dir)
---XXModel
---entitys(Dir)(实体对象)
0_1、构造函数和初始化方法的特点和区别
⑴、参考享元模式,构造负责确定“内部状态”(静态的、配死不变的);初始化负责设置最初的“外部状态”(动态的、可变的)。
⑵、构造函数只调用一次;初始化方法可重复调用(重新初始化);另外,局部刷新方法也可重复调用。
--------------------------------------------------- NRatel割 ---------------------------------------------------
1、数据对象构造和初始化时机:
⑴、游戏启动时,在XXModel单例的构造函数中构造,无需初始化或等待后续服务器消息为其补充初始化和删减多余构造。
(适用:无uid,用本地策划表即可完成构造,多属于系统数据对象)。
(构造时可能得先为所有动态属性赋予默认值)(因为服务器记的数据可能是增量的,如商店,服务器只记玩家买了哪几个档位,没买的档位无需初始化,默认是0)。
(下述⑵⑶⑷⑸均可作为补充和删减)。
⑵、账号或角色登录时,用 “服务器返回的模块初始化消息(所有应在登录时就初始化的模块)” 构造并初始化。
(适用①:无uid,但需要初始化消息才能进行构造(可能和当前角色或服务器状态有关)(如,商店共有10档商品,而受玩家目前的进度或服务器日期限制,只展示其中5档))。
(适用②:有uid,多属于玩家或服务器后期生成的数据对象(如重复获得的英雄卡牌/装备;好友、收到的邮件等))。
⑶、功能解锁时,用 “服务器返回的模块初始化消息” 构造并初始化。
⑷、活动时间开启时,用 “服务器返回的活动初始化消息” 构造并初始化。
⑸、点击某入口进入该功能时,向服务器请求模块初始化数据构造并初始化(懒构造初始化)。
--------------------------------------------------- NRatel割 ---------------------------------------------------
注意点:
⑴、除懒构造初始化外,务必保证客户端从服务器较及时的拿到数据,若在这个拿数据的中间态判断功能是否开放,应认为是未开放。
⑵、功能开放要做数据层和显示层的区分(比如背包,数据层从创角时就开放,但显示上可能要通过X关后才展示入口或移除入口的锁标志)(考虑数据层是否可以完全忽略功能开放?)。
⑶、一个功能的解锁必定(目前没发现特例)是由某个玩家操作请求导致的。
⑷、由时间变化引起活动开放时,应由客户端主动请求(略延时)该活动的数据(短连时)或 服务器推(长连时)。
⑸、一个功能模块,无论是否产生用户/服务器数据,只要它开放了,服务器就应该下发初始化消息(可无内容),主要为了告诉客户端该数据模块已经初始化。
⑹、服务器应保证更新消息发送之前已发过初始化消息。
2、数据对象重新初始化时机:
⑴、切换账号或角色(重登)时。
⑵、任意时刻重新收到“服务器返回的模块初始化消息”时。
⑶、跨天时(自然天和游戏自定刷新天(如每日5点)),请求服务器进行重置(仅当数据含与跨天有关的数据内容时,如 “每日挑战次数”)。
3、数据对象更新时机:
⑴、玩家操作时。
⑵、其他玩家操作时(可能影响好友、公会、共斗副本等)。
⑶、系统自操作时(如,系统AI调整当前规则等)。
4、数据对象清理时机:
⑴、离开功能界面时(一些进入界面时临时创建或获取的数据)。
⑵、活动到期时。
⑶、退出登录时。
三、数据更新原则
0、前提
⑴、一个操作可能使多个数据模块发生变化。
⑵、一个操作可能使数据模块整体重新初始化 或 数据模块内部局部发生增/删/改。
--------------------------------------------------- NRatel割 ---------------------------------------------------
1、服务器仅确认客户端操作 还是 为客户端返回数据?
有些操作,客户端在请求时本身知道将要修改什么数据,可以由客户端自行修改(比如,挑战副本后剩余次数=原次数-1)。
但有些操作,客户端在请求时不知道将要发生什么,必须由服务器返回(比如,打开一个随机宝箱,不知道可能开出什么)。
建议均由服务器返回数据,一方面为了统一,另一方面前者更易出错且不太安全。
2、覆盖更新还是差异更新(服务器为客户端通知最新值还是差异量)?
对于一个原子性单元(值类型或一个较小较独立的对象),大部分时候应该覆盖更新。
对于一个杂糅对象(较大、较分散数据的打包)需要定义针对性修改某个值的协议(避免修改时对其他内容产生影响)(尤其是部分数据属于个人,部分数据属于服务器时)。
对于一个集合,尤其是较大集合,应该差异更新(增/删/改其中的某些项)(需要注意,这不是幂等的)。
对于一个深层嵌套的集合,应该每层都可更新。
3、数据更新协议制定
对于数据模块整体重新初始化,可直接使用初始化消息,命名为 {#ModuleName}_Info。
对于数据模块内部集合中对象的增/删/改,可制定每层对应的变化消息,命名为 {#ModuleName_#Layer1_Layer2...}_Change。
(游戏中所有增/删/改的枚举值可统一为1,2,3)
(同一层的多个对象变化消息,服务器应该合并,否则可能导致客户端多次计算和刷新)
一个请求将带回一堆数据变化的推送消息和一个返回
四、数据模块间依赖
各数据模块是否按手动配置的顺序进行初始化?
实例如:
道具模块逻辑依赖了角色模块(是否新获得保存时需要按角色uid);
英雄模块逻辑依赖了道具模块(是否可升阶取决于升阶道具数量是否足够);
功能开放模块依赖了主线、英雄等模块(是否解锁取决于主线关卡进度、英雄星级等);
抽卡功能依赖了功能解锁模块(通关X关后解锁抽卡);
功能解锁模块又反过来依赖了抽卡功能(基础抽卡达到N次作为一个解锁条件);
限时签到活动依赖了活动开放模块;
红点数据树依赖了各个对应模块;
......
可以看到,各模块之间的依赖关系是非常杂乱的,像一个网,根本不能理清谁先谁后。
但,还是可以根据其特征,将其分为两大类:
⑴、基础模块(可由“服务器初始化消息”直接初始化的模块);
⑵、上层模块(可能获取或缓存下层模块的数据结果 亦可能 对下层模块的数据进行加工处理并缓存结果))。
分类举例:
⑴、基础模块:角色模块、道具模块、主线模块、英雄模块、抽卡模块...
⑵、上层模块:功能解锁模块、红点数据树...
--------------------------------------------------- NRatel割 ---------------------------------------------------
要求!:
⑴、模块间应互相独立,初始化时完全不分先后顺序。
⑵、各基础模块在初始化时,不要调取其它模块的数据(不能保证其他模块已经初始化完成)。
⑶、各基础模块在初始化时,不要直接缓存自身模块内的计算结果为变量(具体处理参考下节:中间变量和缓存优化)。
⑷、上层模块在初始化时,不要缓存任何其他模块的结果,只需要建立空壳(具体处理参考下节:中间变量和缓存优化)。
⑸、上层模块的变化由基础模块的变化通知。
五、中间变量和缓存优化
1、定义
中间变量(自定名字),含义:不由服务器直接存储维护,而是由客户端根据基础数据进行二次推导计算和缓存的变量。
实例如:
英雄等级由英雄经验从1级开始逐级扣除计算得到。
英雄战斗力由英雄八维属性、所穿装备、携带宠物、全局称号等计算得到。
共鸣水晶的等级取战力前五英雄的最低等级(等级相同时按品质、uid降序)(《剑与远征》中)。
项目后期发现启动/登入游戏慢,原因是初始化中做了太多事,想把部分变量改为懒初始,但引用处太多难以重构。
...
2、三种方案的维护成本对比:
⑴、不维护中间变量(不缓存结果),只定义一个Get方法,获取时现算。
读成本:高; 写成本:低; 维护成本:低
读成本取决于获取频次,若获取频次高,则计算次数多,可能有性能问题;若获取频次低,如仅1次时,则读成本也不高。
⑵、维护中间变量(缓存结果),每次引起中间变量变化时重新计算,获取时返回缓存值。
读成本:低; 写成本:高; 维护成本:适中
写成本取决于引起中间变量变化的频次,若变化频次高,则计算次数多,可能有性能问题;若变化频次低,如仅1次时,则写成本也不高。
⑶、维护中间变量(缓存结果)和脏标记,变化时标记为脏,获取时若为脏则重新计算,否则直接返回缓存值。
读成本:适中; 写成本:接近0(只是在写脏标记); 维护成本:较高
在变化频率和读取频率都高或不确定时适用。最智能,将自动决定重新计算的次数。
3、注意点和选择考量
⑴、总是应该先定义一个Get方法,在方法内部再去决定是否缓存结果(利于后期调整策略)。
⑵、主要考量读写频率(推荐)。
⑶、也可考量计算复杂度(不推荐)。若本身计算复杂度很低,用方案⑴即可,即使频繁计算也问题不大。
六、数据变化应该如何通知显示更新?
1、首先要明确:
⑴、一切数据变化的源头都来自操作。可能是玩家操作、系统操作(AI 或 时间变化等)或 服务器操作。
⑵、对于刷新UI界面来说,大部分都是瞬时完成的,没有异步过程。当有异步动画时,重要的可锁屏等待,不重要的可直接打断,这里不细讨论。
⑶、一处显示可能同时引用多个数据模块的数据。
⑷、多处显示也可能同时引用同一数据模块的数据。
2、界面刷新可通过两种基础方式(如图中步骤 5_1 和 5_2):
⑴、(步骤 5_1) 修改数据后,由操作直接调用界面提供的刷新方法(界面存在时)。
⑵、(步骤 5_2) 修改数据后,由派发数据改变的事件,界面上注册的此事件被触发,调用其刷新方法(界面打开时注册事件,界面关闭时移除)。
优缺点对比:
⑴、基于操作的刷新,需要判断当前存在哪些需要刷新的界面(通常较难判断且耦合性强(不利于修改)),但能保证时序(各数据模块都更新完成后才执行更新显示)。
⑵、基于数据的刷新,可以解耦,但无法保证时序、丢了操作时的环境、还可能有刷新效率问题(意外的界面刷新 或 相同位置多次刷新,见下方注意⑴)。
设想有没有更理想的方式?:
一个操作,等所有数据模块修改完成后统一触发一次显示刷新,再统一触发一次红点刷新。
todo...
初始化 -> Model1, Model2, ...
-> View1, View2, ...
-> Red1, Red2, ...
操作 -> TheModel1, TheModel2, ...
-> TheView
-> Red1, Red2, ...
3、注意:
⑴、刷新界面事件的粒度会影响界面刷新效率。
若太大,可能带来意外的界面刷新(数据A、B属于同一对象,本想刷A处显示,B处也被刷新了);
若太小,相同位置可能刷新多次(对象X、Y被同一处显示引用,当X、Y变化时此处显示将被刷新两次)。
⑵、刷新界面事件应基于协议自动生成,避免手动定义,若有性能瓶颈,考虑可针对性手动处理一部分。
⑶、界面的刷新接口应有粗有细,应该尽量调用较小的刷新方法,而不是偷懒全部刷新。
⑷、有时,操作可能不带来任何数据变化,如只是打开/关闭某界面;
⑸、有时,操作可能只改变界面内临时数据,不影响存档中的数据模型;
七、其他注意点:
1、类的对象直接用协议定义的还是重新定义?
重新定义。
协议数据只可读,就像静态表也一样。但类的对象是可修改的。
若直接使用,可能引起错误。
2、数据类/显示类对外提供接口的原则?
接口应该细碎,在使用处组合,而不是直接提供组合接口。
因为组合的可能性太多,会导致模块庞杂。
若提供组合接口,仅从方法命名上看,就可能出现很多近义的方法名,会让使用者难以辨认和挑选。
如有必要,可以提供一个 XXXHelp 类,用来提供各种组合方法。
大对象接口函数的形参列表应尽量短小,避免各种组合重载。
可以直接以“可配置对象”为参数进行传递(微信小程序大量接口均如此设计),扩展起来会特别方便,但它有内存代价。
大对象包含较多显示信息时(有必选、有可选)
不要将可选参数放在构造或刷新接口,而是提供独立的、对称的显示方法。
(注意,构造或刷新时需要以默认方式调用这些可选方法)
如 英雄小卡牌,有必选信息:背景、框、头像; 有可选信息:品质、种族、等级、星级。
提供可选接口 showQuality(bool)、showCamp(bool)、showLevel(bool)、showStar(bool);
---------------------------------------
3、pb 消息是否应该包含可选值?
不应该。
因为接受者分不清发过来的是“数值上等于默认值的真实值”还是不愿赋值的可选值。
解决:要么将这个接口的数据完整更新覆盖,要么拆分成小接口。
4、数据结构的选择?
集合数据应考虑增删查改的频率,若不确定,可先用字典。