Design Hub

    • 注册
    • 登录
    • 搜索
    • 版块
    • 最新
    1. 主页
    2. Pamisu
    3. 帖子
    • 资料
    • 关注 0
    • 粉丝 0
    • 主题 22
    • 帖子 65
    • 群组 1

    Pamisu 发布的帖子

    • 自用C#代码规范

      在没有代码规范的情况下,一个多人合作的项目中很可能会出现多种代码风格,就像是一栋房子的装修中同时出现中式古典风格、现代简约风格、欧式奢华风格以及亚马逊原始风格。抛开美观不谈,这对项目成员间的协作以及后续维护会造成较大的阻碍,所以统一的代码规范是必要的。

      本规范综合参考Godot C# Style Guide、微软的C# Coding Style与Unity Code Style Guide,可作为Godot、Unity、.NET等C#项目代码规范。

      文中未特别说明部分优先遵循Godot C# Style Guide,其次遵循微软的C# Coding Style。

      格式

      本地文件中的换行符在提交到git后应转换为LF,而不是CRLF或CR,一般情况下git默认开启此功能。

      使用UTF-8无BOM编码,如果使用Visual Studio需要注意设置。

      使用4个空格作为tab键缩进,一般这是Unity、VSCode、Rider的默认设置,Godot需要在Editor Settings -> Text Editor -> Behavior -> Indent中修改。

      花括号换行使用Allman风格,而非K&R风格:

      while (x == y) 
      {
          DoSomething();
          DoSomethingElse();
      }
      
      if (x > 0)
      {
          DoSomething();
      }
      

      内容只有一行时,可省略花括号:

      while (x == y) 
          DoSomething();
      
      if (x > 0)
          DoSomething();
      

      属性的get/set方法,以及方法体较为简单时,可写成一行:

      public interface MyInterface
      {
          int MyProperty { get; set; }
      }
      
      public class MyClass : ParentClass
      {
          public int Value
          {
              get { return 0; }
              set
              {
                  ArrayValue = new [] {value};
              }
          }
      
          public int Foo() { return GetSomeValue(); }
      
          public void Bar() => DoSomething();
      }
      

      字段、属性、方法之间的空行不要超过2行。

      方法内语句之间的空行不要超过2行。

      保持代码紧凑易阅读,不要无意义空行。

      命名

      基础

      C#代码文件使用PascalCase命名,例如MyClass.cs。

      不使用默认命名空间,即必须指定类所在的命名空间,命名空间使用PascalCase。

      namespace Game 
      {
          public class MyClass
          {
          }
      }
      

      C# 10.0以上可使用文件范围的命名空间。

      namespace Game;
      
      public class MyClass
      {
      }
      

      命名空间与文件夹结构尽量保持一致,例如namespace Game.Combat.Characters,则其文件夹结构为Game/Combat/Characters。

      接口使用PascalCase命名,加“I”前缀。

      接口成员的访问修饰符没有要求,其他地方使用显式访问修饰符。

      public interface ITouchable
      {
          // 接口成员可省略访问修饰符
          void Interact();
      }
      

      枚举使用PascalCase命名,不加任何前缀。

      public enum DrinkType
      {
          None,
          Soft,
          Hard
      }
      

      类与结构体使用PascalCase命名,不加任何前缀,基类可用“BaseXxx”命名但不是硬性要求。

      public class SomeClass
      {
      }
      

      所有常量使用PascalCase命名,无论其访问修饰符是什么,包括局部常量,且不使用任何前缀如“k_”。

      public class SomeClass
      {
          public const float DefaultSpeed = 10f;
          private const string LogTag = "MyClass";
      }
      

      所有private字段,无论是否为static,均使用camelCase命名,加下划线前缀,除此之外不使用其他前缀如“m_”、“s_”、“t_”等。

      public class SomeClass
      {
          private static T _instance;
          private static readonly object _lockObj = new();
          private Vector3 _aimingAt;
          private Vector3 _velocity;
      }
      

      非private字段使用PascalCase命名。

      public class SomeClass
      {
          protected int HitPoints;
          internal int State;
          public string Name;
      }
      

      所有属性使用PascalCase命名,无论其访问修饰符是什么。

      public class SomeClass
      {
          private bool IsAlive => HitPoints > 0;
      
          protected float MyProperty { get; set; }
      
          public float AnotherProperty
          {
              get { return MyProperty; }
          }
      }
      

      所有方法使用PascalCase命名。

      public class SomeClass
      {
          public void MyMethod() 
          {
          }
      }
      

      局部变量及方法参数使用camelCase命名,不加任何前缀。局部常量使用PascalCase命名。

      public float SomeMethod(float someValue) 
      {
          const float Increment = 1.2f;
          var result = someValue + Increment;
          return result;
      }
      

      可读性

      含有三个字母及以上的缩写词时,遵循当前命名约定,例如“APIHandler”在PascalCase时写作“ApiHandler”,camelCase时写作“apiHandler”。

      两个字母的缩写词为特例,例如“UIUtil”在PascalCase时写作“UIUtil”,camelCase时写作“uiUtil”,仅限首字母缩写词,例如“Id”不是首字母缩写。

      命名应该尽量表达清晰,尽量达到自解释,缩写不要影响可读性,例如:

      FindNearbyEnemy()?.Damage(weaponDamage); √
      
      FindNode()?.Change(wpnDmg); ×
      

      bool变量不加任何固定的前缀,例如“b”。但推荐使用“is”、“has”等词来表明其含义,例如“isDead”, “isWalking”, "hasDamageMultiplier"。

      尽量使用动词短语为事件命名,用动词时态区分事件发生的时机。事件不加任何前缀或后缀。

      public event Action OpeningDoor;    // 开门事件发生之前
      public event Action DoorOpened;     // 开门事件发生之后
      

      事件的接收方法以“On事件名”命名。

      public void OnOpeningDoor() 
      {
      }
      
      public void OnDoorOpened() 
      {
      }
      

      非特殊情况不使用拼音命名。

      避免拼写错误,很多时候拼写错误会造成一些让人一时摸不着头脑的Bug(例如JSON序列化、服务端传值错误等等),建议开启编辑器的拼写检查功能。

      示例

      namespace MyGame;
      
      // 类与结构体使用PascalCase命名,不加任何前缀
      public class MyClass<T, R> : Parent<T, R> where T : class, new()
      {
          // 所有常量使用PascalCase命名
          public const float DefaultSpeed = 10f;
          private const string LogTag = "MyClass";
      
          // 所有private字段使用camelCase命名,加下划线前缀
          // 使用显式访问修饰符,不省略private
          private static T _instance;
          private static readonly object _lockObj = new();
          private Vector3 _aimingAt;
          private Vector3 _velocity;
      
          // 非private字段使用PascalCase命名
          protected int HitPoints;
          internal int State;
          public string Name;
      
          // 所有属性使用PascalCase命名
          private bool IsAlive => HitPoints > 0;
      
          protected float MyProperty { get; set; }
      
          public float AnotherProperty
          {
              get { return MyProperty; }
          }
      
          public static T Instance
          {
              get
              {
                  if (null == _instance)
                  {
                      lock (_lockObj)
                      {
                          _instance ??= new T();
                      }
                  }
                  return _instance;
              }
          }
      
          // 所有方法使用PascalCase命名
          public void MyMethod()
          {
              int[] values = {1, 2, 3, 4};
              int sum = 0;
      
              for (int i = 0; i < values.Length; i++)
              {
                  switch (i)
                  {
                      case 3: return;
                      default:
                          sum += i > 2 ? 0 : 1;
                          break;
                  }
              }
      
              i += (int)MyProperty;
          }
      
          // 使用显式访问修饰符,不省略private
          private int MyMethod2() 
          {
              return 0;    
          }
      }
      
      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Unity Animator在物体被禁用时保留状态与参数

      默认情况下,物体被禁用时Animator会回到初始状态并且重置所有参数,要保留状态和参数,使用:

      animator.keepAnimatorStateOnDisable = true;
      

      需要Unity 2018.1以上版本。

      发布在 游戏开发
      Pamisu
      Pamisu
    • NodeBB服务器迁移记录

      记录一下NodeBB从旧服务器(Windows Server 2016)迁移到新服务器(Ubuntu 22.04)的流程。官方文档只有搭建说明,没有迁移说明,中途踩了一些坑。

      备份旧服务器上的数据

      从官方的升级文档中得知(猜测),需要备份数据库以及NodeBB源码文件夹下的Uploads文件夹,另外还要备份源码文件夹下的configs.json。

      数据库MongoDB

      这里使用的是MongoDB,先从MongoDB官网下载命令行工具:
      https://www.mongodb.com/try/download/database-tools

      使用其中的mongodump备份数据库:

      mongodump -h 数据库服务地址:端口 -d nodebb -o 备份输出路径 -u nodebb
      

      -h 数据库服务的地址和端口,如果是本地127.0.0.1:27017则可以省略

      -d 数据库名称,NodeBB数据库名称默认为nodebb

      -o 备份文件输出到哪里

      -u 数据库用户,NodeBB数据库用户默认为nodebb

      然后会提示要输入数据库nodebb用户的密码,密码输入正确后会输出备份文件:

      Pasted image 20240221124458.png

      uploads与config.json

      uploads文件夹的路径为nodebb源码目录/public/uploads,全部复制出来即可,config.json在根目录下。

      在新服务器上重新搭建

      在旧服务器上(Windows Server),nodebb是直接搭的,由于新服务器系统换成了Ubuntu,所以打算改用Docker搭建。

      Docker

      按照官方的Docker Engine安装说明 来安装就行。

      FTP

      搭一个FTP来传输备份的文件,既然装了Docker,那就用Docker来搭,方便很多。

      在/home下创建一个文件夹用来存放FTP传输的文件:

      mkdir -p /home/vsftpd/files
      

      运行FTP容器:

      docker run -d -p 20:20 -p 21:21 \
      -p 21100-21110:21100-21110 \
      -v /home/vsftpd/files:/home/vsftpd \
      -e FTP_USER=用户名 \
      -e FTP_PASS=密码 \
      -e PASV_ADDRESS=服务器公网ip \
      -e PASV_MIN_PORT=21100 \
      -e PASV_MAX_PORT=21110 \
      --name vsftpd \
      --restart=always fauria/vsftpd
      

      -d 以守护进程形式运行

      -p 20:20 -p 21:21 映射容器的20和21端口到宿主机

      -p 21100-21110:21100-21110 映射容器的21100到21110端口到宿主机,用于被动模式

      -v /home/vsftpd/files:/home/vsftpd 映射容器内的/home/vsftpd目录到刚才在宿主机中创建的目录

      -e FTP_USER -e FTP_PASS 用户名密码

      --name 容器名称

      --restart=always 容器退出时总是重启容器

      fauria/vsftpd 镜像名

      运行起来之后,在阿里云安全组中允许20、21端口以及21100到21110端口的访问:

      Pasted image 20240221124044.png

      本地使用被动模式连接:

      Pasted image 20240221124158.png

      连上之后,把uploads文件夹、configs.json传到服务器上,数据库备份文件可以不传。

      MongoDB

      理论上来说,可以用Docker Compose一键部署MongoDB和NodeBB,官方仓库中有docker-compose.yml,但这里要部署的NodeBB是2.x版本不是最新的3.x,试了一下没整出来,时间关系就先不搞Docker Compose了。

      创建一个文件夹用于存放MongoDB数据与配置文件:

      mkdir -p /home/nodebb/data/
      

      在这个文件夹下,再创建一个db文件夹用于存放数据,创建一个mongo.conf文件用于数据库配置:

      mkdir db
      vim mongo.conf
      

      修改mongo.conf的内容:

      systemLog:
        destination: file
        path: /var/log/mongodb/mongod.log
        logAppend: true
      storage:
        dbPath: /data/db
      security:
        authorization: enabled
      

      运行MongoDB容器:

      docker run -p 27017:27017 \
      -v /home/nodebb/data/db:/data/db \
      -v /home/nodebb/data/mongo.conf:/data/configdb/mongo.conf \
      --name mongodb \
      --restart=always \
      -d mongo -f /data/configdb/mongo.conf
      

      -v /home/nodebb/data/db:/data/db 映射数据库目录
      -v /home/nodebb/data/mongo.conf:/data/configdb/mongo.conf 映射配置文件
      -d mongo -f /data/configdb/mongo.conf 指定配置文件运行

      MongoDB容器跑起来后,进入到容器内做一些配置:

      docker exec -it mongodb mongosh admin
      
      # 使用管理员数据库
      use admin
      # 创建管理员账号
      db.createUser( { user: "admin", pwd: "管理员密码", roles: [ { role: "root", db: "admin" } ] } )
      # 管理员登录
      db.auth("admin", "管理员密码")
      
      # 使用nodebb数据库
      use nodebb
      # 创建nodebb数据库用户
      db.createUser( { user: "nodebb", pwd: "密码", roles: [ { role: "readWrite", db: "nodebb" }, { role: "clusterMonitor", db: "admin" } ] } )
      # 退出
      exit
      

      运行起来之后,如果要临时从本地连接,在阿里云安全组中允许27017端口,用完再关掉(或者用防火墙关)。

      本地机器可以使用MongoDB Compass检查是否能正常连接,再使用MongoDB命令行工具中的mongorestore恢复数据:

      mongorestore -h 数据库服务地址:端口 -d nodebb --dir 备份文件路径 -u nodebb
      

      输入密码后开始恢复,恢复后的数据库:

      Pasted image 20240221131253.png

      NodeBB

      Docker方式

      理论上来说Docker方式是绝对可以的,但这里由于旧服务器上的NodeBB是2.x版本,新服务器如果安装3.x版本会不兼容,看了一下好像没有现成的NodeBB 2.x版本镜像,只能自己构建,最终还是偷懒使用了NodeJS部署方式,之后有空再研究下Docker,或许可以尝试下面的命令:

      docker run --name nodebb \
      -p 4567:4567 \
      -v /home/nodebb/public/uploads:/usr/src/app/public/uploads \
      -e URL="域名" \
      -e DATABASE="mongo" \
      -e DB_HOST="host.docker.internal" \
      -e DB_USER="nodebb" \
      -e DB_PASSWORD="数据库密码" \
      -e DB_PORT="数据库端口" \
      -d NodeBB镜像
      

      NodeJS方式

      先按照官方说明中的Installing Node.js一节把Node.js装好。

      装好之后,安装git,克隆NodeBB 2.x版本源码:

      # 安装git
      sudo apt-get install -y git
      # 克隆源码
      git clone -b v2.x https://github.com/NodeBB/NodeBB.git nodebb 
      # 进入到文件夹下
      cd nodebb
      

      然后把备份的uploads文件夹和config.json文件覆盖到源码目录下,运行nodebb设置:

      ./nodebb setup
      

      由于config.json已经有内容,不出意外的话只会进行安装,而不需要重复做设置。

      然后运行:

      ./nodebb start
      

      如果运行成功,可以尝试访问服务器ip:4567端口(记得在安全组中开放端口),看网页是否运行正常。

      nginx

      最后用nginx将nodebb代理到80端口。

      首先创建nginx需要用到的目录:

      mkdir -p /home/nginx/conf
      mkdir -p /home/nginx/log
      mkdir -p /home/nginx/html
      mkdir -p /home/nginx/ssl
      

      创建一个临时的nginx容器,复制需要的文件:

      # 容器中的nginx.conf文件和conf.d文件夹复制到宿主机
      # 生成临时容器
      docker run --name nginx -p 9001:80 -d nginx
      # 将容器nginx.conf文件复制到宿主机
      docker cp nginx:/etc/nginx/nginx.conf /home/nginx/conf/nginx.conf
      # 将容器conf.d文件夹下内容复制到宿主机
      docker cp nginx:/etc/nginx/conf.d /home/nginx/conf/conf.d
      # 将容器中的html文件夹复制到宿主机
      docker cp nginx:/usr/share/nginx/html /home/nginx/
      
      # 删除正在运行的nginx容器
      docker rm -f nginx
      

      修改/home/nginx/conf/conf.d/default.conf:

      server {
          listen 80;
      
          server_name 域名;
      
          location / {
              proxy_set_header X-Real-IP $remote_addr;
              proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
              proxy_set_header X-Forwarded-Proto $scheme;
              proxy_set_header Host $http_host;
              proxy_set_header X-NginX-Proxy true;
      
              proxy_pass http://127.0.0.1:4567;
              proxy_redirect off;
      
              # Socket.IO Support
              proxy_http_version 1.1;
              proxy_set_header Upgrade $http_upgrade;
              proxy_set_header Connection "upgrade";
          }
      }
      

      重新运行ngnix容器:

      docker run \
      -p 443:443 -p 80:80 \
      --name nginx \
      -v /home/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
      -v /home/nginx/conf/conf.d:/etc/nginx/conf.d \
      -v /home/nginx/log:/var/log/nginx \
      -v /home/nginx/html:/usr/share/nginx/html \
      -v /home/nginx/ssl:/etc/nginx/ssl/ \
      -d nginx
      

      在安全组中开放80和443端口,如果访问服务器ip能看到NodeBB页面,则说明ngnix运行正常。

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 疯狂踩坑之这是一个服务器迁移测试贴

      @金桔柠檬茶 测试测试测试

      发布在 综合讨论
      Pamisu
      Pamisu
    • 疯狂踩坑之这是一个服务器迁移测试贴

      我去,不容易啊😂😂😂

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      反思一下在自己项目和别人项目中遇到的国际化/本地化问题

      • 程序直接在代码里写死文本/资源路径
      • 翻译后的文本一般在接近项目完成时才能给到,在这之前要有办法提前测试,Godot有一个“伪本地化”模式,可以把文本临时翻译成火星文,没变成火星文的地方说明有遗漏
      • 文本大小/长度适应,不同语言、字体下的文本长度和大小会不一样,设计和制作UI时需要提前考虑到
      • 字体替换,静态本地化通常不会同时包含多个字体,针对不同语言的版本需要能切换字体,同时需要注意切换字体后是否能正常显示,没做好文本大小适应的情况下很可能换字体后一大片都不显示
      • 动态本地化要提前封装好相关组件(文本、图片等),并且严格禁止对原生组件直接设值的行为(不然就做不到动态了)
      • 国际化/本地化表格多人协作问题,暂时没什么头猪,倒是见过用csv的
      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Unity Addressables踩坑

      某些情况下,需要在走进度条时提前加载接下来要用到的资源,以免用时再加载导致长时间卡顿,一个写法是:

      因为要预加载的资源可能会有很多,所以通过标签来加载,将所有同时标有“Preload”和“Combat”标签的资源加载到内存:

      // 预加载部分
      var labels = new List<string> { "Preload", "Combat" };
      await Addressables.LoadAssetsAsync<Object>(labels, null, Addressables.MergeMode.Intersection).ToUniTask();
      

      因为要预加载的资源类型不只有GameObject,还有Sprite、Material等等,这里指定类型为Object,这样可以加载所有类型的资源。

      然后到具体的使用处,使用地址来加载需要的资源:

      Debug.Log(Time.frameCount);  // 测试打印当前帧1
      // 通过地址加载SomePrefab
      var prefab = await Addressables.LoadAssetAsync<GameObject>("SomePrefab").ToUniTask();
      Debug.Log(Time.frameCount);  // 测试打印当前帧2
      

      按理来说,SomePrefab在之前已经被预加载了,这里应该会立即返回结果,然而实际测试发现并没有在同一帧内执行:

      61a69c65-52d2-49cf-8ddd-a2bc72b84986-image.png

      通过Event Viewer查看,也发现SomePrefab在预加载时被加载了一次,使用时又被加载了一次,等于没预加载。

      到底怎么烩柿呢,尝试修改预加载部分:

      var labels = new List<string> { "Preload", "SomeScene" };
      await Addressables.LoadAssetsAsync<GameObject>(labels, null, Addressables.MergeMode.Intersection).ToUniTask();
      

      只是将Object修改为了GameObject,再次运行发现预加载生效了,使用时立即返回了已经加载好的SomePrefab:

      c6a0b8d9-4e71-4698-8c20-536e98a8f94d-image.png

      具体原因还没有深入研究,总之先记录一下。

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Godot Unity 控制按钮点击区域(奇形怪状的按钮)

      Godot中,TextureButton中有一个Click Mask属性,可以设置一张位图来控制可点击区域,白色部分会触发点击,黑色部分不触发:
      57304114-ffe1-4761-9ba1-8d0324116a99-6eea0d12b8a9781482433737d3109249.jpeg

      在Unity中同样有办法实现,首先按钮图片的导入设置中需要开启Read/Write:
      56b5beb5-a9c3-404d-a780-6d80e07c5423-image.png

      然后在脚本中获取到按钮上的Image组件,修改其alphaHitTestMinimumThreshold 属性:

      private Image _clickMaskImage;
      
      [SerializeField]
      private float _clickMaskAlphaThreshold = .1f;
      
      private void Awake()
      {
          _clickMaskImage = GetComponent<Image>();
          _clickMaskImage.alphaHitTestMinimumThreshold = _clickMaskAlphaThreshold;
      }
      

      这个属性的默认值为0,图片所在的方形区域都会拦截点击事件,当值大于0时,图片中透明度大于该值的地方才可以被点击。

      论坛里关于这个问题的讨论,2020年之前的解决方案都相对比较复杂:
      https://forum.unity.com/threads/none-rectangle-shaped-button.263684/

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 就要在这里发电!

      59e0f23c-dc51-46f1-ba28-ec72aa0d406e-image.png

      发布在 综合讨论
      Pamisu
      Pamisu
    • 这几年开发思维上的一些转变

      一些个人的想法和流水账。

      菜鸟的思考

      刚工作做Android APP的时候,可以说完全是一个菜鸟,做了几年后,摸到了一些架构方面的皮毛,也算稍微有些成长。

      那时候对程序设计的一些认知,大概是知道整个软件系统是需要分层的,以便于解耦和复用。就APP来说,通常会分成通用基础库、通用业务层、应用层(即最终的APP)。通用基础库、通用业务层都包含若干个模块,项目需要用到哪些,就把对应的模块拿来拼装组合,应用层再根据项目需求做定制,一个项目做完后,再看看有哪些东西可以沉淀到底层,整合进去。

      得益于包管理工具Maven和Gradle,这些底层模块都可以打包发布到私库上,APP中一行代码就能引用,使用和管理都很方便。

      遇到的问题是,项目上做二次开发的人员经常来给研发反馈,“这个功能我想改一下,但是被你的代码\视图限制了,改不了”、“你写的功能完全满足不了需求,我只能重写”、“我用不着这个功能,能不能去掉”、“我想直接改你的源码,能不能把源码发我一份”、“你底层怎么有Bug,会不会写代码”...

      总的来说都可以归于程序设计能力不扎实,对业务理解不透彻,以及妄图写出一套万能的业务层来解决未来所有的项目需求。按照开放封闭原则,软件实体应该对扩展开放,对修改封闭,现在想想,当时写的底层模块只想着要涵盖哪些业务,而忽略了扩展性。需求总是会变的,一个灵活易扩展的底层才是解决问题的正确思路。

      对第三方库可以说是非常依赖,能用现成的绝不自己造轮子,比如网络请求、数据持久化、异步操作、依赖注入、图片加载等等,开新项目的第一件事就是在Gradle脚本里加上一大堆依赖项,至于对包大小的影响?基本上是无人关心,反正这种基础库也不会大到哪里去。

      在当时(甚至现在)的APP开发中这是一个很常见的现象,Google下场推出Jetpack全家桶之后,也只是把这些库换成官方的了而已。面试中也经常考查对第三方库的熟悉程度。我自己负责过一段时间面试,对候选人基本上也是问这些,没办法,俺就这水平,更高深的也问不出来了。

      好处自然是很明显的,省去了自己鼓捣轮子的时间,降低了开发门槛,今天学会这一套,明天就能来我司上班。这也是当时培训班越来越火爆的原因之一,只要会用就能干活,至于理论基础是否扎实,写出来的东西性能如何,很多情况下都不在乎。

      但是如果抱有“我会用就行了,没必要搞懂原理”这样不求甚解的想法,自己的水平就很难提升了。特别是现在这个面试造火箭入职拧螺丝的环境下,很容易被别人卷死。

      但我那时在优化方面也只是了解皮毛,只知道尽量避免内存泄漏,减少APP启动时的耗时操作,垃圾回收器怎么运作的知道个大概。资源这块只知道尽可能复用,尽量让美术切9图。

      至于产品设计上没有学到太多,可能是没有遇到靠谱的产品经理。

      项目管理方面,作为技术负责人,首先应该制定并执行好开发标准,确保团队中所有人写出来的代码、做出来的东西都是同一种风格,这样做对于开发和维护都有很大的帮助。有些标准甚至都不需要自己从零开始制定,直接在一些大厂公开的标准上修改即可,像之前我们用的是阿里的开发标准。

      同时在开发方面还要有全局的掌控,例如团队中每个人做什么、怎么安排更合理、每个人负责模块的业务逻辑、大致实现方式(程序概要设计、详细设计)、哪些东西需要与别的部门对接、开发进度如何等等。

      很可惜我并不是一个合格的技术负责人,对待组员太宽松,不怎么追进度,不会带动积极性。很多时候组员反馈太难的做不了的东西,都是拉过来自己做,而不是教给他们做的方法,确实这样可能会更快更好,但是长期下来自己累得要死,自己的水平越来越高,其他人的水平却没有得到提升,进入了恶性循环。当然跟当时其他人的工作态度也有一定的关系,随着几个大佬的离开,剩下的大部分人都是养老心态,难的不做新东西不学,教了也不太愿意听,这种环境下还是早点跑路为妙。后来在只有几个人的创业团队里,这些问题几乎不存在了。

      一些思维转变

      再后来因为一些原因,逐渐放弃Android转向Unity。踩过一些坑后意识到,性能这块要花的功夫比以前要多得多,特别是移动端,代码真不是随便写写就行的,一不小心写出来的东西就没法跑了。

      性能相关

      最基础的性能相关知识是必须要具备的,由于Unity的GC方式较为落后(现在已经有计划在新版本迁移到CoreCLR GC),稍有经验的Unity程序员都是谈GC色变——GC工作时很容易引起掉帧。所以像内存分配、值类型引用类型、拆装箱等等一定要搞清楚。对于哪些操作是“昂贵的”要有了解,比如常见的GetCompoent、GameObject.Find、反射调用等等。这样在写下代码之前,会有认知“这样写会增加GC负担,得换种方式”、“这个操作比较耗时,不能搞得太频繁”,从而写出高质量的代码,而不是在功能写完后才发现运行疯狂掉帧、内存暴涨,这时再优化就很困难了。

      开发中对目标平台要有清晰的认知,了解各平台的限制。比如只准备上PC,那不在乎上面那段内容都没太大关系,代码写得再垃圾都是能勉强一战的。如果目标平台是移动端,除了代码上要注意,还要多多在真机上运行测试,编辑器能跑不代表真机能跑,很多疑难杂症都只会在真机上出现。如果目标平台是WebGL(小游戏),资源加载一定要用异步,不能使用线程等等。

      如果是从PC端开发转向移动端开发,就要特别注意,已经见过一些不太好的例子:

      • 状态机框架,使用反射来调用每个状态的生命周期函数,这种做法确实会更方便和解耦,但反射的性能是很差的,在移动端的表现不会太好,这时不能图自己用起来方便,应该改成直接调用或事件驱动;

      • 事件系统,每种事件都用class定义,每当发送事件时就要new一个class,增加GC工作量,为什么不试试神奇的struct呢。

      • 属性系统,设计上属性类中包含int、float等字段,均为不同类型下的属性值,为了方便,设值函数这样写:

      public void SetValue(object obj)
      {
          if (obj is Int32)
          {
              intValue = (int) obj;
          }
          else if (obj is Single)
          {
              floatValue = (float) obj;
          }
      }
      

      导致调用时发生装箱和拆箱。

      上面三个例子,都是很底层的部分,在游戏中会频繁用到,底层的性能不做好,整个游戏的性能也就无从谈起了。

      不仅是自己造的东西,第三方库也要谨慎使用。刚接触Unity的时候,我的思路还跟搞Android时差不多,有现成的为什么不用现成的呢,AssetStore上不是有一大堆吗?所以比较热衷于学习各种插件。但后来逐渐认识到,用的前提是这东西适用,比如它能用在哪些平台上?性能如何,是否有针对移动端优化?引入这个库会增加多少包大小?扩展性和兼容性如何,能在它基础上做改造吗?所以现在发现之前买过的一些插件反而用不上了,很多时候还是得自己造。

      资源这块,在移动端上则是能省则省。UI图片能切9图的一定要切,能染色的就不要重复做多种颜色的素材,如果美术给出的素材不合理,程序一定要提出,而不能说美术给啥我就用啥。

      在不影响功能的情况下,能压缩的图片一定要尽量压缩,导入选项里顺手调整一下压缩选项,或者做个导入预设。对于较大的图片或图集,压缩后能节省十多MB的运行内存甚至更多。

      对于图集,也不是无脑打就行了,想想打图集的目的:节省存储空间、减少Draw Call,减少Draw Call这一点可以说是牺牲了部分内存占用换来的,加载图集中的一张图,整个图集都要加载到内存。所以打图集时应该是把可能会同时渲染的图片打在一起,同时要避免这张图集过大,用时加载,不用时卸载以释放内存。见过一些不好的例子,把不同功能模块用到的图片资源打在一起,导致刚进游戏就加载了多张很大的图集,内存占用飙升,这在小游戏平台是很致命的。

      这些都只是偏基础的,其他的像渲染、美术这些方面的经验还不多,过几年再来补充吧。

      单例模式

      主要在于单例的生命周期管理。在Android中,用到的更多是依赖注入,要用到某个类的实例,注入一下就好了,不用太关心它何时创建何时销毁。Unity虽然也有依赖注入框架(VContainer之类),但目前个人用的不多。在Unity中使用单例遇到了哪些问题呢?一般来说,最传统也是最常见的单例写法大概是这样的:

      public class Singleton<T> where T : class, new()
      {
          private static T _instance;
          
          private static readonly object LockObj = new();
          
          public static T Instance
          {
              get
              {
                  if (null == _instance)
                  {
                      lock (LockObj)
                      {
                          _instance ??= new T();
                      }
                  }
                  return _instance;
              }
          }
          
      }
      

      MonoBehaviour单例大概是这样的:

      public abstract class SingletonBehaviour<T> : MonoBehaviour where T : MonoBehaviour
      {
          public static T Instance { get; private set; }
      
          [Tooltip("保持不销毁")]
          public bool dontDestroyOnLoad = true;
      
          protected virtual void Awake()
          {
              if (Instance != null)
              {
                  Destroy(gameObject);
                  return;
              }
              Instance = this.GetComponent<T>();
              if (dontDestroyOnLoad)
                  gameObject.DontDestroyOnLoad();
          }
      
      }
      

      自动创建型的MonoBehaviour单例大概是这样的:

      public abstract class SingletonAutoBehaviour<T> : MonoBehaviour where T : MonoBehaviour
      {
          protected static T _instance;
          
          public static T Instance
          {
              get
              {
                  if (_instance == null)
                  {
                      GameObject go = new GameObject();
                      go.name = typeof(T).ToString();
                      _instance = go.AddComponent<T>();
                  }
                  return _instance;
              }
          }
          
      }
      

      当功能逐渐复杂时,整个游戏系统中通常会有许多单例,它们在许多地方都被调用。如果自动创建型单例(第一种和第三种)使用得很多的话,将会面临一个问题:每个单例到底是啥时候创建的?

      你可能会说,“打个断点看一下不就知道了”,“这个问题很重要吗?”。确实,重点不在于单例是什么时候创建的,而在于单例的创建顺序是混乱的,这种情况下无法控制谁先创建谁后创建,很可能会扎堆创建,有些单例的创建可能还非常耗时。

      这将引起一些致命的问题:单帧内初始化对象过多、耗时操作过多导致卡顿、刚进入游戏时就初始化了一些还用不着的东西、加载了一些还用不着的资源、拖慢游戏的启动速度等等。

      相应的,单例的销毁也缺少统一的管理,切换场景时,一堆dontDestroyOnLoad的单例依然保留,假如需要退出到开始界面重新游戏,这一堆单例的状态都需要重置,稍有遗漏就很容易出现问题。

      所以在新的框架中,我逐渐抛弃了这种“野生”的单例,改为统一管理。

      模块间解耦合

      “高内聚低耦合”的口号谁都会喊,但真要做到却没那么简单。记得还是个菜鸟的时候,我说出过“要改UI层,那逻辑层也不得不改啊”这种话,现在看来是非常搞笑的。像Android开发中经常可以看到MVC、MVP、MVVM、MVI这些架构思想,目的之一就是降低耦合度。

      解耦的最大好处,就是在面对改动时可以不用“牵一发而动全身”,修改甚至删除掉某一个模块,对其他模块不会产生很大的影响。新手程序常犯的错误就是模块间严重互相依赖,比如直接在UI中写业务逻辑,业务逻辑反过来又需要UI。还见过一些不好的例子,整个游戏必须要等待所有UI初始化完才能正常游戏,UI一旦被销毁,游戏的业务逻辑都没法正常跑。

      这些问题不仅仅是解耦方面,在职责划分方面也没有做好,违反了单一职责原则,UI层就应该只做UI的事情,图一时省事直接把业务逻辑写在UI中,只会给后续的扩展与维护带来更大的麻烦。

      那么有哪些东西需要解耦?

      从整个软件系统来看,首先分层是有必要的,至少通用的基础库需要分出来,可以用Assembly Definition把各个模块隔离开,例如:

      |-- CommonLib  基础库模块目录
      |   |-- CommonLib.asmdef
      |   |-- 代码文件...
      |-- Framework  框架层模块目录
      |   |-- Framework.asmdef
      |   |-- 代码文件...
      |-- Game  游戏应用层模块目录
      |   |-- Game.asmdef
      |   |-- 代码文件...
      `-- Editor  游戏编辑扩展模块目录
          |-- Editor.asmdef
          |-- 代码文件...
      

      这里Game依赖Framework,Framework依赖CommonLib,由于程序集的划分,低层模块将无法引用到高层模块,只会存在高层到低层的单向引用。在具体实现中,高层模块还可以不直接依赖低层模块中的具体实现,而是依赖抽象类与接口,进一步降低耦合。

      在这基础上,继续划分成更细的维度(拆分出更多的模块)。回到有哪些东西需要解耦这个问题,个人的看法是,逻辑部分需要与表现部分解耦,比如Gameplay中有Gameplay的逻辑与表现,UI有UI的逻辑与表现(不知道Gameplay这个词是否恰当,总之这里是指与UI无关的玩法层面的东西)。

      例如玩家角色与怪物战斗,玩家的各项属性值受到其穿戴装备的影响,战斗中各种数值的计算发生在Gameplay逻辑层,而玩家角色与怪物的显示在Gameplay表现层;

      UI上打开背包,有哪些装备要显示、装备的各种属性、是否有可升级的装备等等,这些是从Gameplay逻辑层获取的,这些装备具体按哪种方式显示、点击之后有哪些交互,这些是在UI逻辑层处理的,而装备的实际显示、图标大小、属性文字、按钮摆放、交互动画等等,这些是在UI表现层的。

      在这种划分下,逻辑层和表现层各司其职,这样就杜绝了上面提到的“在UI中写业务逻辑”的情况。

      实际开发中,需要根据根据具体项目情况决定解耦的程度,比如Gameplay的逻辑和表现可能关联紧密,不是那么地好拆分,而UI逻辑和UI表现在相对简单的情况下,拆分可能会多出许多代码,增加工作量,这种情况下可以把Gameplay逻辑与表现和业务逻辑统一看作逻辑层,UI的逻辑与表现统一看作表现层。

      那么这样逻辑层和表现层之间是否完全解耦了呢?有一种方法可以检验解耦是否做到位,把表现层删除,如果逻辑层不需要做太大调整甚至不调整就能正常跑,那就说明做对了。其实这也是很常见的需求,比如省电模式、息屏挂机,就是把表现层关掉,但不会影响到逻辑层的运行。

      但由于逻辑层与表现层之间必然存在交互,交互过程可能还是会产生耦合,比较常见的交互实现方式一般有这些:

      1. 逻辑层模块与表现层模块互相依赖,通过函数的直接调用来实现双方的交互。
      2. 逻辑层模块与表现层模块依赖对方的抽象层,通过调用抽象类或接口来实现双方的交互。

      1这种方式是最简便但耦合度也是最高的,存在直接的依赖,如果其中一方发生变化,另一方很可能要同步修改;2则使用抽象类和接口进行解耦,但缺点是需要维护更多的抽象类和接口,增加了开发难度。

      个人认为,相较于上面两种方式,使用事件驱动的模块间交互可以更加优雅地解决问题。

      事件驱动下的解耦合

      这里的事件系统必须要具备一个重要特性:事件的接收者只需要订阅事件并做对该事件的处理,而不需要关心事件有没有人发送、发送者是谁;相应的,事件的发送者只需要在合适的时机发出事件,而不需要关心事件有没有人接收、接收者是谁。这个特性实现起来很简单,就不详细说明了。

      在这个特性下,解耦的目标便自然达到了。逻辑层和表现层不再需要互相依赖,也不再需要依赖对方的抽象层,它们只需要依赖一个或多个事件定义层,事件定义层中只需要定义逻辑层与表现层中需要的事件、以及事件所引用到的数据结构(甚至数据结构可以放到通用的模型层),并且事件可以在多个功能中复用、组合,相对于上面的方式2,需要维护的内容大大减少。

      并且由于发送者和接收者互不关心、互相没有感知,上面提到的“删掉表现层,逻辑层照常运行”的需求也自然而然地实现了——表现层被删除后,逻辑层不再接收到来自表现层的事件,带来的影响只是缺少了来自表现层的输入,逻辑层发往表现层的事件没有人接收了,逻辑层自身不会受到影响。

      由于事件广播发出,逻辑层发出的事件可以被表现层的多处接收,逻辑层发出一个事件,表现层多处都可以做处理,从而解决“由于某些页面数据未刷新而导致显示不一致”的问题。

      举个例子,背包功能需要能在背包一级页面显示背包内所有物品的简略信息(品质、图标、数量、等级等),点击某物品弹出二级页面(对话框形式)展示物品详情,点击二级页面中的强化按钮可以升级物品。

      按上面的模式来设计:

      • 数据模型层

        • 背包物品相关数据模型定义
      • 事件层

        • 请求获取背包物品事件
        • 请求获取物品详情事件
        • 请求升级物品事件
        • 物品发生变化事件
      • 逻辑层

        • 背包逻辑,实现对背包内物品的管理与物品升级
      • 表现层

        • 背包一级页面,以网格形式展示背包物品,展示每个物品的品质、图标、数量、等级
        • 背包二级页面,展示物品详情,监听强化按钮的点击事件

      其中事件层依赖数据模型层,逻辑层依赖事件层与数据模型层,表现层依赖事件层与数据模型层。

      此时背包展示到升级物品、刷新页面的流程:

      • 背包逻辑初始化时,监听 “请求获取背包物品事件”、“请求获取物品详情事件”、“请求升级物品事件”。

      • 背包一级页面开启时,发出 “请求获取背包物品事件”,背包逻辑收到该事件后,返回当前背包内物品数据(这个的具体实现方式有很多,例如通过回调返回、通过委托返回、另外发出事件返回等)。背包一级页面收到该数据后,执行自身的展示逻辑将物品显示,随后监听 “物品发生变化事件”。

      • 同理背包二级页面开启时,发出 “请求获取物品详情事件” 获取物品详情并展示,随后也监听 “物品发生变化事件”。

      • 背包二级页面的强化按钮被点击时,发出 “请求升级物品事件”,背包逻辑收到该事件后,检查当前是否符合升级条件,执行物品强化升级逻辑,随后发出 “物品发生变化事件”,将物品变化的相关信息广播出去。

      • 背包一级页面与背包二级页面将同时收到 “物品发生变化事件”,根据其中包含的物品变化信息刷新自身页面。

      由于充分解耦,一个功能的逻辑层和表现层可以拆分给不同的开发人员,双方只要约定好数据和事件格式,就可以开始各自的开发,最后进行对接。开发过程中逻辑层和表现层都可以独立测试,而不需要等待对方开发完毕。

      这种模式的缺点是不可避免地会定义大量的事件,对于事件的分类管理以及后续维护的要求较高;在设计时需要避免出现死循环,例如A事件的接收者发出了B事件,B事件的接收者收到后又发出了A事件;由于事件的频繁使用,事件系统的实现一定要做到高效、零GC。

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Godot 4.1 对比

      5bc7fa93-efac-41e6-a3a9-ce49ede8ab7a-image.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 【游戏设计】一套完整的商业游戏包含的内容

      补充一些想到的,有些可能比较偏细节

      通用后台

      • 版本功能开关控制(或许可以归类到通用的后台配置中)
        • 分版本分平台控制,主要用于iOS提审时临时屏蔽提审版本的部分功能

      通用系统

      • 热更新(客户端、服务端)

        • 资源热更
        • 代码热更
      • 国际化

        • 静态国际化:一个游戏版本对应一种语言,不可在运行时切换语言
        • 动态国际化:游戏内包含多种语言,可在运行时切换语言
      • 新手引导

        • 引导触发条件
        • 引导表现形式(是否有遮罩、是否高亮目标、是否可打断、附带剧情等等)
        • 引导显示层级需要注意不能错乱,例如被更高层级的视图遮盖
      • 功能解锁

        • 解锁条件(根据游戏内容而定,例如通过某个关卡、完成某个主线任务等等,程序设计时需要提前预留相关接口)
        • 锁定与解锁表现可以提前定义好,做成可复用的形式
      • 任务与成就

        • 达成或触发条件(同样程序设计时需要预留相关接口)
        • 进度的计算方式,从任务激活开始、从游玩开始、从当天登录开始等等
      • 定时刷新和重置

        • 例如道具的刷新、Boss的刷新、排行版重置等等
        • 刷新的时机(每日、每周、每版本、手动)
      • 充值

        • 订单创建、支付、发货、自动补单、人工处理掉单
        • 支付流程:SDK-游戏服务端-游戏客户端三端间的交互和校验
      • 第三方能力接入

        • 支付:Google Play, Apple Pay, 米大师, 客服充值, 支付宝等等
        • 广告:广告位、广告填充、冷却处理
        • 数据上报(自己有则不需要)
        • 聊天(也可以自己造!)
      发布在 游戏设计
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      个人版跳过Unity闪屏Logo

      创建一个脚本,粘贴以下代码即可:

      namespace Game
      {
      #if !UNITY_EDITOR
          using UnityEngine;
          using UnityEngine.Rendering;
          using UnityEngine.Scripting;
      
          [Preserve]
          public class SkipSplashScreen
          {
              [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSplashScreen)]
              private static void BeforeSplashScreen()
              {
      #if UNITY_WEBGL
                  Application.focusChanged += Application_focusChanged;
      #else
                  System.Threading.Tasks.Task.Run(AsyncSkip);
      #endif
              }
      #if UNITY_WEBGL
              private static void Application_focusChanged(bool obj)
              {
                  Application.focusChanged -= Application_focusChanged;
                  SplashScreen.Stop(SplashScreen.StopBehavior.StopImmediate);
              }
      #else
          private static void AsyncSkip()
          {
              SplashScreen.Stop(SplashScreen.StopBehavior.StopImmediate);
          }
      #endif
          }
      #endif
      }
      

      首场景越小,效果越好,首场景较大的情况下,可能会出现Logo一闪而过的情况。通常来说,游戏的首场景也不应该放太多东西,这样可以尽可能地精简首包大小,提高启动速度。

      发布在 游戏开发
      Pamisu
      Pamisu
    • Godot创始人对从Unity迁移的答疑解惑

      Pasted image 20230916233228.png

      自从9月12日Unity公布了新的收费模式后,一部分开发者开始转向免费开源的Godot引擎。为了让开发者少走一些弯路,Godot创始人Juan Linietsky这几天发布了一系列推文,针对引擎各方面做了一些答疑,有些东西是官方文档没有提到的,更偏向于“在引擎作者眼中这个引擎应该怎么用”,值得翻译记录一下。

      包含以下内容:

      • 概念上的对应关系
      • 场景树理念与Unity的区别
      • GDScript还是C#
      • 如何应对高性能需求
      • Servers API的使用(绕过场景系统直接使用底层API以达到极致性能)

      概念上的对应关系

      实体:节点
      组件:节点
      场景设置:节点
      导航:节点
      光照贴图:节点
      视口:节点
      行为:节点+脚本
      预制体:场景
      场景组合:场景
      ScriptableObject:资源

      几乎所有内容都是节点、场景或资源...Unity中很多非常复杂的子系统,在Godot中它们表达得更加自然和直观。

      因此,我建议新用户首先熟悉 Godot 的工作原理及其背后的价值是什么。我理解(想尽快迁移到Godot的)这种冲动,但仅仅是把Godot当成Unity一样来转换游戏工程,很可能会导致很多痛点。

      对于场景树的建议

      我能给那些从 Unity 转向 Godot 的人的最好建议是:
      你必须将Godot的“场景树”想象为“一棵不包含任何实体的组件树”。这有两个特殊点:

      • 场景一目了然,更加清晰
      • 组合更加灵活

      Pasted image 20230916231009.png

      你不需要在每个节点上挂多个脚本来实现某种行为,只需要添加更多子节点,子节点上再挂脚本即可。
      此外,预制体或场景组合,在概念上是并不存在的。你可以简单地在其他地方实例化或继承任何场景。你还可以让它们的实例变得可编辑并做一些本地更改。

      Pasted image 20230916231239.png

      译注:Godot中,一个节点上只能挂一个脚本,相比Unity中脚本是作为组件挂载到物体上,Godot中的脚本更像是节点功能的扩展

      最后,要理解每个场景没有“全局设置”的概念,场景只是节点。 Unity 中属于场景的事物,例如光照贴图、导航、环境等,在Godot 中仍然只是节点,允许根据需要混合和匹配任何内容。

      Pasted image 20230916231744.png

      即使你在 Godot 中加载场景,引擎也会将其放置在一个“根”节点下。什么是根节点?一个窗口节点!没错,如果你想在游戏中使用多个窗口,请实例化更多窗口并将子节点放入其中。

      Pasted image 20230916231823.png

      个人认为这个是Godot非常有特色的地方,甚至有人做出了可以在多个窗口之间跳跃的2D平台类游戏

      这些事情需要一些时间才能理解,但最终发生的事情是 Godot 让你彻底颠覆游戏开发的过程:

      • 在 Unity 中,你用代码设计游戏,并使用编辑器作为一个工具
      • 在 Godot 中,你在编辑器中设计游戏,然后添加代码..

      由于 GDScript 实际上是编辑器紧密集成的一部分(并且该语言是深度集成的),因此使用它进行开发的体验甚至比使用在单独的 IDE 中编辑代码的引擎更加流畅。这就是大多数 Godot 用户更喜欢它的原因。

      Pasted image 20230916232231.png

      这些概念上的差异有助于大多数 Godot 用户提高工作效率。你很可能注意到一些 Godot 项目,甚至是那些没有太多使用该引擎经验的用户制作的项目,在今年过去的大型游戏大赛(GMTK、Ludum Dare 等)中取得了不错的成绩。

      所以再次强调,在尝试从 Unity 1:1 转换你的游戏之前,在移植项目之前,请花点时间采取行动并充分理解 Godot 的设计哲学。我最不希望的是 Unity 用户在尝试过程中受到伤害并获得糟糕的体验。

      关于脚本语言GDScript

      为什么 GDScript 存在?在 Godot 的背景下,有两个主要原因是用户的首选:

      • 快速迭代
      • 深度融合

      Pasted image 20230917085947.png

      快速迭代: 当你忙于开发游戏时,代码部分不会妨碍你:

      • 无构建时间,即时运行
      • 集成编辑器,快速打开附加到节点的脚本
      • 立即重新加载正在运行的游戏中的更改(热重载)
      • 同时调试游戏数据和代码..
      • 没有GC..

      GDScript使用引用计数,而不是垃圾回收器:内存管理 。根据官方文档介绍,是为了避免GC工作时引起的卡顿和不必要的大量内存占用。

      深度集成:

      • 大量与引擎紧密相关的特性:节点路径语法、onready、IDE中的可视化连接、预加载关键字等等
      • 与引擎共享数据模型,允许零消耗的查看变量、序列化、网络化等
      • 将变量暴露给编辑器无需胶水层

      节点路径语法:

      例如有一个这样的场景:

      Pasted image 20230917091252.png

      使用$加上路径即可获取子节点,或者将节点拖入到代码编辑器中自动生成代码:

      var col = $CharacterBody2D/CollisionShape2D
      

      集成甚至更加深入:

      • 代码自动完成可以自动提示游戏数据(节点路径,文件路径,动画名称,对象中的实时数据等)
      • 可以从编辑器拖入各种东西到代码中,自动生成相关代码(节点路径、文件路径、属性等)
      • 内置的代码编辑器和检查器

      总而言之,Godot 用户更喜欢使用 GDScript,因为这种针对引擎量身定制的深层次集成。就像虚幻中的蓝图。 所以,Godot 并不是打算让 C# 成为二等公民,C# 已经尽可能地集成了(但没办法达到GDScript的高集成度)。

      个人看法和建议:

      虽然个人更熟悉C#,但在上手Godot的过程中依然是先使用了GDScript,包括参加Game Jam、制作一些个人项目,都是使用GDScript。如果你之前使用过Python或Lua,那么GDScript是非常容易上手的,给它一些尝试,说不定它是你的菜。

      正如上面所说,GDScript与引擎的集成度是最高的,对开发效率有不错的提升,相比C#在开发中没法享受到高集成度带来的便利(例如拖拽节点生成代码、可视化信号连接等等)。

      性能上根据社区做的测评,C#是比GDScript快的,如果非常注重性能,C#是更好的选择;如果想在GDScript中提升性能,那么使用静态类型,避免使用动态类型。

      如果对强类型有要求,或者希望代码尽可能与引擎解耦,这种情况下GDScript可能不是好的选择,虽然支持静态类型,但它本质上还是动态类型语言,不支持接口(只有鸭子类型),没有很强的类型检查;与引擎集成度高也导致它更难与引擎解耦。

      Godot 3.x LTS 版本下,C#支持全平台打包,在目前最新版Godot 4.1中,C#不支持移动端和WebGL打包。这是由于3.x 使用的是Mono,而现在Mono已废弃,4.x版本改用.Net,没有IL2CPP那样的魔法加持,需要等待微软官方做移动端和WebGL的支持,预计时间是今年底。

      多种开发语言问题

      我从 Unity 开发人员那里看到的一个持续的担忧是,由于 Godot 设计为支持多种语言(不仅仅是 C#),这将导致插件碎片化。 Godot 使用通用语言适配器 API,因此目标是你可以使用任何语言的任何插件。

      Pasted image 20230917103844.png

      虽然这仍然不是 100% 完善(现在 GDScript 可以使用 C# 和 C++ 插件,C# 只能使用 C++ 插件,但两者都不能使用 GDScript),在架构上已经可以实现这一点, Godot 中的所有语言都使用相同的引擎 API。

      老实说,我希望大多数复杂的附加组件都用 GDExtension (C++/Rust) 或 C# 编写。 借助他们都已经使用的通用 Godot 语言 API 适配器,应该很容易实现互通。

      好奇这是如何运作的?基本上,Godot 4 使用 C 中公开的 API 适配器:

      https://github.com/godotengine/godot/blob/master/core/extension/gdextension_interface.h

      C 非常高效,这个星球上的每种语言编程都可以与它交互。此外,C 是 ABI 稳定的,确保当前和未来的互操作。

      顺便说一下,在编写或使用插件时,这对你来说是完全透明的,你将在文档中看到 API,并且只需从你喜欢的语言中使用它即可。

      性能问题

      我从迁移到Godot的Unity用户那里看到的另一个常见主题是,Godot是如何处理类似用Burst/ECS编写的代码的? 这是以不同的哲学方式处理的,节点没问题,但是如果处理数万个节点,性能就会受到影响,那么该怎么办?

      在 Godot 中,有两种方法可以获得“更快”的性能,特别是对于大量实体:

      • 用底层语言重写脚本(C++/Rust/等)
      • 使用Servers API

      Pasted image 20230917102602.png

      正如我之前提到的,Godot 使用通用语言适配器 API。这意味着,如果你有一个带有脚本的节点并且需要对其进行优化,你可以简单地用更快的语言(C++、Rust,甚至 C#)重写它,并且对于其余代码来说它将是透明的。

      一般来说,需要优化的部分很小(比如永远不会超过整个游戏的 5%),其余部分 GDScript 可以很好地处理。 但如前所述,如果使用节点,在处理数以万计的实体时可能会遇到问题,这种情况下应该使用Servers API。

      Godot 提供了两个抽象层:场景层(Scene)和服务层(Servers)。场景及其节点是一个高级的、非常灵活的抽象。但Godot中所有的底层操作都是在服务层完成的。在Godot中,你可以轻松绕过场景层,直接使用服务层。

      使用底层语言和Servers API,你可以获得最大性能,并且仍然保留使用 Godot 的所有可移植性和易用性优势,同时该代码可以与游戏的所有高级代码顺利交互。

      Godot 4.x支持Compute Shader,但这里没有提到太多

      下面是对Servers API文档的部分翻译

      使用Servers优化性能

      使用Servers优化性能

      就像大多数引擎那样,Godot的场景系统使用节点与资源来简化项目内容的组织和资产的管理,以此制作复杂的游戏。但是显然,这样做有以下缺点:

      • 这又会导致一层额外的复杂性
      • 性能比直接使用简单 API 时要低
      • 不可能使用多个线程来控制它们
      • 需要更多的内存

      大部分情况下,这并不是问题(Godot 进行了非常多的优化,大多数操作都使用信号处理,因此不需要做轮询)。尽管如此,有些情况还是不能满足要求,例如,每帧需要处理数以万计的实体可能会达到性能瓶颈。

      Godot最有趣的设计决策之一是整个场景系统是可选的。虽然目前还不能将其单独提出来,但是在运行时可以完全绕过它。

      与Unity类比,就像是绕过Renderer组件,直接调用底层API渲染

      在核心部分,Godot 使用了服务层的概念。它们是用于控制渲染、物理、声音等的非常底层的 API。场景系统是建立在他们之上,并直接使用他们。最常见的服务层有:

      • RenderingServer: 处理图形相关
      • PhysicsServer3D: 处理3D物理相关
      • PhysicsServer2D: 处理2D物理相关
      • AudioServer: 处理音频相关

      查看它们的API可以发现,提供的函数都是Godot允许你做的所有事情的底层实现。

      使用服务层的关键是理解资源 ID (RID)对象。这些是服务器实现的不透明句柄。它们是手动分配和释放的。服务器中的几乎每个函数都需要 RID 来访问实际资源。

      大多数 Godot 节点和资源都在内部包含了来自服务层的这些 RID,可以通过不同的函数获得它们。事实上,继承 Resource 的任何内容都可以直接强制转换为 RID,然后可以将资源作为 RID 传递给服务层 API。但是,并非所有资源都包含 RID(在这种情况下,RID 将是空的)。

      下面是一些使用Servers API的示例:

      创建Sprite

      extends Node2D
      
      # RenderingServer需要维持一个纹理引用
      var texture
      
      func _ready():
          # 创建一个CanvasItem
          var ci_rid = RenderingServer.canvas_item_create()
          # 设置当前节点为父节点
          RenderingServer.canvas_item_set_parent(ci_rid, get_canvas_item())
          # 将纹理画在CanvasItem上
          texture = load("res://my_texture.png")
          # 使用RenderingServer添加到渲染
          RenderingServer.canvas_item_add_texture_rect(ci_rid, Rect2(texture.get_size() / 2, texture.get_size()), texture)
          # 旋转45°,变换位置
          var xform = Transform2D().rotated(deg_to_rad(45)).translated(Vector2(20, 30))
          RenderingServer.canvas_item_set_transform(ci_rid, xform)
      

      Canvas Item API允许你往Canvas上画东西,一旦添加,它们便无法修改,需要清除然后再次添加(变换位置、旋转不需要清除)。

      通过这个函数来清除:

      RenderingServer.canvas_item_clear(ci_rid)
      

      用过SFML可能会对这种方式比较熟悉(但又有一些不同),SFML中几乎每帧都需要调用draw和clear

      创建Mesh

      extends Node3D
      
      # RenderingServer需要维持一个网格引用
      var mesh
      
      func _ready():
          # 创建一个3D实例.
          var instance = RenderingServer.instance_create()
          # 设置scenario,这样这个实例才会出现当前世界中
          var scenario = get_world_3d().scenario
          RenderingServer.instance_set_scenario(instance, scenario)
          # 添加网格
          mesh = load("res://mymesh.obj")
          RenderingServer.instance_set_base(instance, mesh)
          # 移动网格
          var xform = Transform3D(Basis(), Vector3(20, 100, 0))
          RenderingServer.instance_set_transform(instance, xform)
      
      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: [Unity]小游戏转换优化笔记

      23.09.08

      已经...没有什么运行性能方面的优化了...

      如果在项目之初就已经确定目标平台是移动端或小游戏,在基础框架选型、造轮子、后续的开发中都注意了上面提到的各种问题,每个开发人员都有对性能优化的基础理解,那么只要客户端逻辑不是非常重度,相信性能都不会太差;

      但如果项目初期没有做这方面的考量,使用的基础框架未针对目标平台优化,后续开发时较少关注性能优化(或者没有认知),写完功能后缺乏真机环境下测试,连最核心的游戏逻辑单独拎出来都没法在云测试上跑到及格分,这种情况只能说是无力回天,还是多花点时间重构吧

      发布在 游戏开发
      Pamisu
      Pamisu
    • [Unity]小游戏转换优化笔记

      做Unity转换小游戏时的心情就像骑着公路自行车上高速:一会觉得它行、一会觉得它不行、还有时候觉得是自己不行。

      23.06.03

      最近一直在做优化,有些感触,很多方面很重要但又很容易被忽略:

      • 小游戏与原生APP相比,就像是8051机之与PC机,内存和运算能力都有限,还不允许开线程,很多在编辑器、APP上能流畅运行的操作,到了小游戏上不一定能一样流畅,开发中不能轻信在编辑器中的运行效果
      • 内存使用,官方推荐使用的内存不要超过1G,如果Unity预留托管堆内存超过768M,在有些机器上甚至都跑不起来,而纹理、音频、字体都是吃内存大户,需要注意压缩
      • 游戏物体的实例化,在编辑器中只耗时几毫秒,实机运行则可能需要几十甚至上百毫秒,如果一帧内实例化大量物体,很容易造成长时间卡顿或触发单帧内存峰值
      • WebGL不支持Addressables的同步加载方式,只支持异步加载方式,开发初期就要考虑到,不然后面改起来遭老罪
      • 资源的加载与卸载,用时加载,不用时及时卸载避免占用内存
      • 资源包合理分组,避免出现某个包过大、牵扯到很多功能模块的情况、避免包与包之间出现重复引用

      23.08.05

      断断续续做了快两个月了,继续小总结一下,主要是加载方面。

      把官方文档中能做的都做了之后,接下来基本就是游戏自身的调整优化了,对于中轻度单机,影响性能的大概有以下几点(同时也是新手非常容易忽视的):

      • 资源加载与卸载

      • 复杂物体的实例化

      • 单帧内耗时/耗内存逻辑(特别是Awake Start)

      • 配置表的加载

      这些对启动性能和游戏内动态加载一些物体时的影响较大。比起APP,小游戏对启动时间更为敏感,较长时间的加载很容易造成用户流失,如果设计上是先进入大厅的话加载的负担会少一些,而直接进入战斗的游戏对于加载优化的要求则更高。

      对于后者,等所有资源加载完黄花菜都凉了,一个思路是仅集中加载极少部分战斗必要资源(显示上转个菊花),进入战斗后在游玩的同时,陆续加载其他功能模块、UI等等。

      Unity转换微信小游戏必须有第一段Unity Loader的加载过程,根据项目情况(主要是C#代码的多少)耗时2~10s甚至更多,从用户体验上来说,第一段Unity Loader进度条走完后,不应该再展示游戏自身的加载进度条了。

      这个在设计上优化体验也有很多解决办法,这里说回程序方面。

      一招鲜吃遍天

      分帧加载,分帧加载,还是™的分帧加载!配合UniTask食用更佳。

      资源加载与卸载

      启动时加载的资源涉及到的资源包应该尽量少,可以通过Addressables的Event Viewer查看启动时加载了哪些bundle,用Analyze分析引用关系,然后做拆分。
      Addressables在加载较大资源时是有可能引起帧数波动的,严重时甚至会掉到10多帧,如果此时没有进度条的保护,而是处于游玩阶段,体验自然会非常糟糕。对于音频,可以考虑用流式传输,用平台提供的接口而不是Unity的接口来播放。
      资源的卸载算是基本功,但不知道为什么很多人都做不好(更恶劣的是会做但偷懒不做),做一个功能但不做资源卸载在早期测试中不容易发现问题,但后期甚至线上出现爆内存再来改就为时已晚了。
      卸载的原则也很简单就是当一个资源在未来不会用到,或者很长一段时间不会用到,并且有合适的卸载时机,则应该将其卸载以释放内存。卸载的时机可以参考物体销毁和主动GC的时机,比如出副本、切换关卡等等,尽量不要让玩家感知到卡顿。
      由于Addressables内部使用引用计数,加载和卸载应该成对出现,LoadAssetAsync 与 Release 成对,InstantiateAsync 与 ReleaseInstance 成对,引用计数为零时才会真正地卸载。
      下面是一个反面教材:

      private async void InstantiateSomeGo()  
      {  
      	var go = await Addressables.InstantiateAsync("SomePrefab").ToUniTask();  
      	// 然后不管了
      }
      

      一般来说会自己再封装一层,管理加载过的资源或实例化过的物体,需要时卸载。

      复杂物体的实例化

      掉帧的元凶之一,物体过于复杂时,实例化耗时越久、牵扯到的资源也可能越多。例如实例化一个含有100个子物体的预制体,首先Addressables要加载它所有直接和间接依赖资源所在的bundle,然后从bundle中加载出这些资源,这是第一个耗时阶段;随后将在1帧内执行100多个物体的实例化,包括所有组件的Awake OnEnable Start,这一趟下来CPU和内存都被搞得叫苦不迭,如果机器也有神,写出这种代码的程序员恐怕是第一批被降下神罚的。
      解决办法就是分帧加载,将预制体下的子物体视情况拆分成其他预制体,例如少的话拆成2~3个,多则5~10个,在实例化完父物体后,分帧实例化子物体到父物体下,如果组件的Awake、Start中有耗时的初始化操作,也可将其挪到一个异步的实例化函数中,分帧调用。
      对于分帧加载,不太推荐使用协程,书写繁琐可读性差并且容易产生回调地狱,用UniTask来async/await是更加优雅的做法。
      一个示例:

      public class Test
      {
          private ParentObj m_ParentObj;
          
          public async void LoadParentObj()
          {
              // 加载父物体
              var parentGo = await Addressables.InstantiateAsync("ParentObj").ToUniTask();
              m_ParentObj = parentGo.GetComponent<ParentObj>();
              // 延时1帧
              await UniTask.DelayFrame(1);
              // 初始化父物体
              await m_ParentObj.Init();
          }
          
          public void Release()
          {
              // 卸载物体
          }
      }
      
      public class ParentObj : MonoBehaviour
      {
          private GameObject m_ChildA;
          private GameObject m_ChildB;
          private GameObject m_ChildC;
          private GameObject m_ChildD;
          private bool m_Initiated;
          
          // 避免写Awake Start
          
          public async UniTask Init()
          {
              // 分帧加载子物体,每加载完一个等待一帧
              m_ChildA = await Addressables.InstantiateAsync("ChildObjA", transform).ToUniTask();
              await UniTask.DelayFrame(1);
              m_ChildB = await Addressables.InstantiateAsync("ChildObjB", transform).ToUniTask();
              await UniTask.DelayFrame(1);
              m_ChildC = await Addressables.InstantiateAsync("ChildObjC", transform).ToUniTask();
              await UniTask.DelayFrame(1);
              m_ChildD = await Addressables.InstantiateAsync("ChildObjD", transform).ToUniTask();
              await m_ChildA.GetComponent<SomeComponent>().Init();
              await UniTask.DelayFrame(1);
              await m_ChildB.GetComponent<SomeComponent>().Init();
              m_Initiated = true;
          }
          public void Release()
          {
              // 卸载子物体
          }
      }
      
      public class SomeComponent : MonoBehaviour
      {
          public UniTask Init()
          {
              //初始化操作
          }
      }
      

      用这种方式加载UI时,可以得到比较好的效果,例如加载一个商店页面,可以做到背景->货架->货品这样一个顺序加载显示,而不是卡住一段时间后再全部显示。

      配置表的加载

      因项目而异,不同项目有不同的表加载方式,这里只说下目前遇到的问题,之后再看看鲁班的加载是否会有更好的性能。
      目前的表加载是自己造的轮子,加载过程大致为:加载JSON文件到内存 -> 转换为JSON对象 -> 将每行数据存到数组中 -> 构建 ID:下标 索引字典 -> 释放资源。
      主要耗时在第四步构建索引,需要遍历一遍数据,构建一个Key为ID,Value为数组下标的字典,方便后续根据ID来检索表中某一行的数据。实际测试这一步在不同配置的机器上有很大的差异(应该跟CPU的计算能力有关),一个上万行的表,有些机器不到1s就能跑完,而有些机器则卡住10s之多。
      解决办法可以分帧,也可以考虑拆表精简表,之后有空看看鲁班加载这种表的性能如何。

      接下来是运行性能方面的优化了。

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 最近好累,一直在加班

      最近服务端、客户端、Android端、iOS端、小游戏端的程序工作都摸了一遍,深切地感受到了生有涯而学无涯

      发布在 综合讨论
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      怎么在Luban中分割一个map

      文档仅介绍了列表与bean的分割,例如:

      假装这是一张Excel表...

      ##var       name_list
      ##type      (list#sep=,),string
                      王诛魔,李杀神,刘斩仙
                      张三,李四
      

      会生成 [ "王诛魔", "李杀神", "刘斩仙" ] 等等。

      sep会拆分单元格和字符串,再流式入,sep可以包含多个字符,如 sep=",;",此时会用每个字符来拆分读入的字符串。

      所以对于map可使用:

      ##var        name_power
      ##type      (map#sep=,;),string,float
                      王诛魔,1000;李杀神,1200;刘斩仙,900
                      张三,100;李四,5
      

      会生成 { "王诛魔":1000, "李杀神":1200, "刘斩仙":900 }

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Unity 为什么应该使用obj.CompareTag("Player")代替obj.tag == "Player"

      在使用后者时,Rider会有个提示Explicit string comparison is inefficient, use 'CompareTag' instead,前者是Unity内置的函数,可以避免额外的内存分配。
      那么后者哪里出现了额外的内存分配呢,答案是obj.tag,get方法返回了tag的复制:

          public string tag
          {
            get => this.gameObject.tag;
            set => this.gameObject.tag = value;
          }
      

      这个tag的复制需要被垃圾回收器回收。

      光看上面的代码不太能看出哪里返回复制了,在C#中只要不改变字符串,就不会产生额外的内存分配,查了一下有个说法是旧版Unity中这里会有额外的内存分配,新版已经和CompareTag性能相当,但处于安全性和可读性考虑依然推荐使用CompareTag。

      类似地,使用Input.GetTouch()和Input.touchCount代替Input.touches,使用Physics.SphereCastNonAlloc() 代替 Physics.SphereCastAll()

      发布在 游戏开发
      Pamisu
      Pamisu
    • RE: 一个开发杂记贴

      Unity 新InputSystem如何实现键位配置功能

      How To Implement Key Rebinding | Unity Input System Tutorial
      https://www.youtube.com/watch?v=dUCcZrPhwSo

      406302d9-a530-4930-9b55-fb52cac9aa5e-image.png

      d542eee7-1fda-4f16-9415-1503288f0675-image.png

      那么如何保存键位配置呢

      How To Create Persistent Key Bindings | Unity Input System Tutorial
      https://www.youtube.com/watch?v=DBBbpbIRoIc

      Input System自带将按键配置转为Json的方法,可以将json存起来

      d6619f79-1e6e-4ddd-8f19-294b295d6c4e-image.png

      发布在 游戏开发
      Pamisu
      Pamisu
    • 1
    • 2
    • 3
    • 4
    • 2 / 4