• 我理解的游戏数据模型


    一、游戏数据的意义

    游戏本质是数据(所有模块的数据,共同构成一个完整的游戏数据模型)。
    玩游戏,就是在与服务器交换数据,最后改变这些数据存档。

    二、数据划分

    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 类,在其中提供各种组合方法。

        接口函数的形参列表应短小,避免各种组合,重载出大量接口。
        可以直接以“可配置对象”为参数进行传递(微信小程序大量接口均如此设计),扩展起来会特别方便,但它有内存代价。

    3、pb 消息是否应该包含可选值?

        不应该。
        因为接受者分不清发过来的是“数值上等于默认值的真实值”还是不愿赋值的可选值。 
        解决:要么将这个接口的数据完整更新覆盖,要么拆分成小接口。

  • 相关阅读:
    阿晨的厨艺
    尝试了一个自然语言模型BLOOM
    玩转webpack(02):webpack基础使用
    智能工作流:Spring AI高效批量化提示访问方案
    商城商品的知识图谱构建
    IDEA快捷键记录
    高通Android10 移植exFat格式
    10.20 platform总线驱动
    使用docker搭建mongodb
    【Rust 中级教程】 04 trait (2)
  • 原文地址:https://blog.csdn.net/NRatel/article/details/126045986