开发心得

开发心得

这里的每一点心得都是遇到的坑……

游戏设计

别限制玩家要怎么玩

解决方案技术栈

  • 美术资源管理svn/git-fls
    也可以建2个库,美术资源是Fork的库。这样。美术库只包含基本代码。代码库只包含基本允许场景。
  • 其他资源管理使用git
  • 文档使用 dokuwiki/ amwiki + git /Confluence或代替品
  • 项目管理 (禅道/Redmine) + QQ邮箱 + webqq(调研中)
  • 服务器使用docker Kitematic
    • mysql mongodb
    • Jenkins
  • 使用.vsTemplate规范代码,创建VSIX

版本控制

代码中完成Version类,对应版本配置文件。配置文件必须是可以差分的。
配置文件3个为默认配置。重写配置,指定版本重写配置。后面的配置文件盖前面的。
设计之初,任何功能都要根据版本可配置开启和关闭。每个功能条目配置上下限两个版本。
例如一个功能按钮是否显示,是否禁用,以应对功能随版本开闭。

工作区

创建工作区概念,客户端服务器各种自动化脚本放在一起,所有路径以配置的工作区路径+相对路径为准

配置表

  • ID、数字等不能有隐含信息,所有约束必须明文指出。
    • 错误示例:41,42,十位代表一个类别,个位代表子类别。
  • 元编程,为配置表每个字段生成一个接口,表结构类型是接口的组合、
  • 配置表支持的类型应该为proto3的类型,兼容性更好

数据结构

  • 使用byte代替bool
    少用bool 表示一个属性,尽量用byte。比如一个单位的一个属性时可不可见。由可见不可见改为可见级别。bool改为byte。这样可以设置不同级别的战争迷雾。
    都是一个字节,开销差不多,判断用是否大于0。
  • 使用int代替bool
    int更通用,可以有负值,也方便内存对齐

权衡之后,更倾向使用int表达一个属性

代码细节 架构相关

水平的叫层,垂直的叫模块。比如Megumin.Net网络模块,位于传输层和应用层。


宗旨:尽量多分库,dll不怕多

  • 大约是编程心得和杂项
    基本4层:

    • 4 软件层,程序入口。最开始向 2 注入IoC容器,或IoC配置。所有模块依赖的1中其他模块都需要延迟注入,避免循环引用问题。
    • 3 所有模块接口实现,通过依赖2实现延迟注入。
    • 2 Ioc容器接口,包含所有模块接口(如果使用反射可以省略4)
    • 1 最底层模块接口
    • 0 公共API接口,所有模块共用
    1
    2
    3
    4
    5
    6
    7
    8
    9
      4  	---------------`软件层 IoC容器实现层`
    /|\
    3 | 3 ---------------`模块实现层`(这里可以分为多个子层)
    |\|/|
    | 2 | ---------------`IoC容器接口层`(这里可以分为多个子层)
    |/|\|
    1 | 1 ---------------`模块接口层`
    \|/
    0 ---------------`通用接口层`
  • 代码

    • 不应该跨模块使用enum,除非enum声明在基础层。
      • 定义枚举一定要考虑是否会扩展。枚举扩展非常麻烦。
      • 也要考虑是否有FlagAttribute。
    • 当你负责的模块使用了基础层的枚举,请在enum源码处注明你当前的模块,不能在源码处注明的,在文档中注明。(防止枚举变化带来大量改动)
  • 业务逻辑代码操作的MODEL,应该是接口或者虚类

接口设计

  • 为了兼容AOT,接口定义的方法最好不要带泛型,可以改为泛型接口。IL2CPP或者其他静态生成器生成代码时,无法分析接口中泛型方法实现类的泛型方法实际调用填充类型

类设计

  • 普通类

    • 如果一个类只为另一个类服务,那么他们应该写在一起,或者声明为内部类。
      • 例如:如果只有一个Book类,那么BookManager类则没有必要,应该写成Book的静态函数。
    • 声明 和 new 对象之前想想它的生命周期。
    • 动态的还是静态的
    • 是不是唯一的
    • 如果类的逻辑只使用到类的部分属性,那么应该可以考虑抽象出接口
    • 逻辑只关心部分数据,不意味着要把对象的数据拆开。应该通过接口区分,而不是拆成各个sub子类。
    • 不要用dynamic做形参类型
  • 通讯协议类
    开发阶段协议都是用Json,通过string传递。减少迭代。对协议类接口化。
    发布阶段,将部分协议具体化,提高性能。

    • 通讯类命名
      不建议在类名中加入前缀后缀,例如reqest,response,S2C,C2S,在实际使用中,类名会变得越来长,一个类也未必具有单一功能,S2C的类也可能S2S,失去前缀的意义反而带来歧义。

    • 应答类

      • 必须包含2个内容:

        • 谁做了什么,即请求上下文; Rpc机制解决了这个问题。
        • 导致了什么结果,连锁结果应该放在同一个包中,避免时序错误 类似Race Condition。
      • 应答类中嵌套它对应的请求类是合理的。必须在 更小的包体 和 准确性 之间做出取舍。

      • 如果特殊必要,不要使用增量的方式发送数据,每个消息包一定要独立完整。低频消息不在乎这点数据量。增量发送会导致接收端数据准确性问题。

  • 业务逻辑应不应该直接使用协议类型?

究竟应该继承还是组合?

  • 继承还是组合应该由两个类的相似程度决定,功能相差的多应该用耦合.
    具体到百分比就是,如果类相似程度大于80%,用继承,小于80%用组合.
  • 决定类A要不要继承B,看A与B相同功能占A总功能的百分比决定.
    大于75%就继承.
  • 组合时子元素不应该持有父元素引用,否则就是设计不合理,违反了高内聚低耦合.如果用到父,应该通过传参的方式.
  • 继承和组合 父子关系是反过来的.

代码规范

  • 方法宽度长度不应该大于一个屏幕

  • ///依据:大写开头是安全的,小写开头意味着你可能绕过某些Get/Set逻辑 https://docs.microsoft.com/zh-cn/dotnet/standard/design-guidelines/capitalization-conventions
    Pascal 类 结构 属性 字段 元组参数(本质为字段) 方法 (static readonly) const event
    camel 参数

  • 缩进 4空格

  • 大括号换行

  • = 后换行(代码过长时)

  • =>后换行(代码过长时)

  • 不要使用#region,严重影响代码导航和滚动条。

  • 团队协作时,如果不能统一格式。就不允许使用自动格式页面所有代码。这将导致代码无法追溯。

ID

  • ID 类唯一
  • UID 进程内唯一
  • GUID 全局唯一
  • 对于玩家ID NPCID 怪物ID等实体,共用一个域使用long类型,向前兼容。
    这里不能在乎int/long的性能损失。

时间模块

游戏功能系统设计之初就要将游戏暂停,后台恢复,掉线等情况考虑进去,比如移动,设计成根据时刻采样而不是位置的累加。
使用自己的update而不是unity的update,使用参数UpdateContent 包含 time realtimeSinceStartup frameCount updatemode等,可以指向unity的时间值,也可以重新实现这几个值。
自己实现时间模块可以根据功能自由的进行快进慢放操作,还可以提供可控制的相对时间戳,unity的time存在严重的设计足。

为游戏设计几个状态:

  • offline
  • normal
  • pause
  • pause2normal //暂停到恢复正常的过程,用于处理网络中阻塞的数据。
  • willnormal //进入normal的前一个时刻,通常为一帧
  • connect //连接过程中。
  • reconnect //重连过程中。区分连接和重连十分重要。

通常处理功能模块有多种模式。如移动逻辑:
平滑移动,强制位置对齐,每次移动固定长度等。根据游戏进程的状态选用不同的移动模式,可以获得很好的断线重连效果。

UI设计

  • UI大小(横屏)

    • 画布大小 1280 * 720 (或者) 1334 * 750(iphone6尺寸)
    • 切图大小尽量8的倍数,至少4的倍数。
    • 对于移动端,一以红米Note4x 5.5英寸 1920*1080 像素为例:
      • 大Button,键盘按键的[3-4]/2,6-7个为宜。
      • 小Button,键盘按键的宽度,7-9为宜。
      • 可选物品选单 水平排列半屏 4 个为宜,垂直排列 2.5-3.5 。

    真实项目中可以把UI设计稿截个全屏图放入手机中感受一下。

  • UnityUI代码结构

    • 基于多层继承的代码结构。(设想)

      继承层级 N+2 N+1 N N-1
      预制体层 嵌套层 泛型层 总基类
      实际挂在prefab上的脚本。 仅用于其他功能的嵌套引用。如果unity支持泛型序列化,那么这层完全可以取消 依赖于Model数据的逻辑(名字,堆叠) 不依赖于实际数据的逻辑
      例: ItemIconView1 第一版的物品图标脚本 (abstract)ItemTIconBase ItemIcon<T> (abstract)ItemIconCore Monobehaviour
      ItemIconView2 第二版的物品图标脚本
      ShopItemView1 +(abstractItemTIconBase 商店物品脚本,包含了一个普通物品的图标,当由于需求变化ItemIconView出现大幅度修改时,由于这里引用的父类所以无需改动。
    • 基于接口的代码结构。

      做到你的所有逻辑写在不继承于MonoBehavior的类里即可。



对于文字,图片等资源的ID表示法问题

由于需要文字国际化,我们通常在逻辑中使用一个Key(int/string/enum)表示一句话。那么在内存中我们什么时候将Key转化为字符串Value:

A. 在配置表加载完毕后,Model构建时将Key转化为Value。表现为Model中字段为Value(string)。

B. 在逻辑层面保持Key,在显示到屏幕的最后一刻,也就是View层将Key转化为String。表现为Model中字段为NameKey(Key)。

C. 在逻辑层面保持Key,在显示到屏幕的最后一刻,也就是View层将Key转化为图片等大型资源。表现为Model中字段为AssetKey(Key)。

各个方面优劣对比 A:Name(string) B:NameKey(Key) C:AssetKey(Key)
内存占用 - -
Debug难易 ×
参数传递 - -
跨模块API设计 × ×

结论:对于String使用A方式,对于图片,预制体等可延迟加载的大型资源,使用C方式。


MVC

  • Q:游戏架构中MVC中M到底是什么?
    • A:网络远端传来的数据;配置表;数据库。
      游戏设计中,数据来源总是从这三者之一出发,最终表现在屏幕上。
      Input操作应该转化为指令,然后进入游戏逻辑,构成网络指令和操作指令的统一。
    • 数据+逻辑应该做到可以脱离View运行。

M-V-VM

Q: 为什么要有ViewModel?

A: 比如有一组物品,有个是否是当前正在被选中的属性。如果这个属性放在Model上,那么关闭View,再打开View,这个物品仍然是被选中的。
所以是否被选中应该是ViewModel的一个属性。打开View时,根据Model初始化ViewModel。
所以对于View层,Model不可见,只关心ViewModel即可。ViewModel是Model加其他View需要的属性的组合。

Q:一个model可以对应多个ViewModel么?比如用于3D空间和用于UI?

Q:ViewModel可以Clone/Fork么?

Q: 当model数据发生改变使用dirty轮询机制还是事件机制?

  • dirty机制
    • 只需要在ui界面需要刷新的去看看是否有脏标志 有脏标志就刷新下界面
    • 可能改变多次,但是只需要最新的
    • 轮询可以控制频率和间隔,事件如果短时间巨量变动可能导致大量运算

开发流程

IDEDA -> 确定所属功能 -> 拆分功能明确依赖 -> 明确依赖上下游并标准化接口 -> IDEA.

事务句柄/事务锁/ET6.0协程锁

对于任何需要异步执行/分段/防重入/互斥的事务,应该采用先获取句柄在执行的设计,句柄在(多段)执行过程中传递,在执行结束后复位。句柄可以设计成泛型的,保存事务中间信息,尽量每次使用新句柄,而不是复用句柄,防止脏数据。

对象池

要有全局任务控制。
对于各个功能,使用的频率决定相关功能对象池的权重,以此决定对象池长期保持对象的数量。
把需要加载的东西加入任务队列。控制好内存和帧率的平衡。
良好的设计应该是游戏应该知道什么时间段是性能占用低的时间。
比如打开全屏UI,通常性能非常充足。这个时候全局如果认为内存可用。就会分帧实例化对象池的对象。

值类型在哪里分配内存

取决于声明位置,声明在方法内部,就在栈上. 声明在类的字段上,就在堆上.

不要盲目解耦合

  • 调用方式与耦合程度:
    类型强引用直接调用 > 接口或多态调用 > 事件委托调用 > 字符串反射调用
  • 以下几个情况适合解耦合:
    • 两个类型不在同一个程序集,被引用的程序集中希望调用引用它的程序集中的函数
    • 两个类型不是由同一个程序员实现,互相不能有效沟通
    • 类型在编码时未知,引用在编码时未知,由运行时动态生成

编程三个核心技巧,理解每种技巧都是编程路上的分水岭.

  • 回调函数->委托事件
  • 泛型
  • 多线程->异步

编程境界

  1. 世间万物皆对象
  2. 世间万物皆数据
    解释:分得清什么是数据什么是逻辑,知道数据和逻辑可以互相转化,进而复用。
    例子:各种配置表。
  3. 标准化
    解释:任何事物都需要不停的迭代发展。标准化是快速迭代的必要条件。
  4. 复杂度不会消失,只会转移。同时也意味着可以转移
    • 标准化是一个明确的边界,将复杂度分离到各个子模块中。
    • 需求的最小复杂度是存在的,实现的复杂度总是大于最小复杂度。错误的设计会增加不必要的复杂度。
    • 消灭重复与不必要复杂度的引入,比如Util函数中的十多个传参。
  5. 数据是存在,逻辑是变化。

    以前以为发明二进制的人很牛逼,用0和1就能表达整个世界,洞悉了世界的本质。但其实不是的,二进制也好,十进制也好,只是表达方式的不同。计算机使用二级制,是因为物理材料容易实现,如果材料合适,使用三进制,十进制都是可以的。
    数据与逻辑不因表达方式不同而改变。花言巧语不能改变事物本质。
    数据与逻辑才是万物的基石,二进制不是。

    程序的本质就是数据和逻辑。要写出更通用和准确的逻辑,就需要更高级的数据描述方式。interface和class具有局限性。

  6. 还没有悟出来……

工具链

经验法则: 美术素材只能用工具生成中间素材,不能生成最终素材,因为对于最终素材90%的情况需要手动调整,挂载脚本。一旦由工具重新生成,手动调整部分会丢失,如果工具要保留手动调整部分,要做大量工作。

unity客户端可以开启HTTP监听,然后远程发送调试指令,也可以嵌入到控制台

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void StartHttpDebug()
{
HttpListener listener = new HttpListener();
Debug.Log($"开启HTTP66666 Debug监听 IsSupported:{HttpListener.IsSupported}");
listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous;//指定身份验证 Anonymous匿名访问
listener.Prefixes.Add("http://+:66666/");
listener.Start();
StartRecvAsync(listener);
}

private async void StartRecvAsync(HttpListener listener)
{
var context = await listener.GetContextAsync();
StartRecvAsync(listener);
string rawUrl = context.Request.RawUrl;
if (rawUrl == "/favicon.ico")
{
return;
}
else
{
rawUrl = rawUrl.Substring(1, rawUrl.Length - 1);
}
Excute(rawUrl);
//使用Writer输出http响应代码,UTF8格式
using (StreamWriter writer = new StreamWriter(context.Response.OutputStream, Encoding.UTF8))
{
writer.Write("<HTML><BODY> 收到指令 </BODY></HTML>");
writer.Close();
context.Response.Close();
}
}