Unity独立开发游戏之路
Posted 库卡の巨价
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity独立开发游戏之路相关的知识,希望对你有一定的参考价值。
简单的介绍
本人本科是上海师范大学教育类的专业,由于对于计算机领域的兴趣考研考到了本校的计算机专业。对于跨考计算机的心路历程也许会专门写一篇文章。之所以会尝试自学unity独立开发自己的游戏,部分原因是因为自己对于计算机的领域的兴趣,也有自己喜欢自由自在的创造,还有部分原因是一位多年好友对开发游戏的执着。期间遇到的困难和压力也是非常具有挑战性,我们通过观看各种unity大佬的视频,搜集项目源码进行学习。实现了一个又一个功能,还是挺有成就感。我会挑选几个让我眼前一亮地代码和思路进行记录分享,也是对于自己小结。
我之后所提到的思路和代码并不适合unity初学者,尽管我也只是个初学者。因为我确实没有做游戏软件有个宏观的设计,之前也没接触过任何类似的项目。我属于是想到哪写到哪的那种情况,因此我需要这篇文档来对我自己之前的工作有个回顾。
如果你能给予我任何意见,我一定会认真听取,我也会尽我所能完善这篇文章。
前言
该项目是一款unity2D回合制战略卡牌游戏,简单来说就是每名玩家操控一枚棋子移动,并且利用各个棋子的技能以及卡牌与其他玩家战斗。使用的是Photon网络框架进行联机的设置。
登录界面
实现的功能
玩家输入自己的昵称,然后选择加入的房间。实现起来难度不大。
部分代码
using Photon.Pun;//联网
using UnityEngine.UI;//操作UI,好像暂时用不到
using Photon.Realtime;//使用RoomOptions类设置房间相关信息时需要用用到
using TMPro;//操作TextMeshPro文本需要用到
public class NetworkLaunch : MonoBehaviourPunCallbacks
//unity中的脚本默认继承MonoBehaviour,这里继承PunCallbacks是因为我们需要重写Photon中的回调函数
private void Start()
PhotonNetwork.ConnectUsingSettings();//连接到服务器
public override void OnConnectedToMaster()//回调函数,设置UI可见性
base.OnConnectedToMaster();
nameUI.SetActive(true);
public void OnClickPlayBtn()//输入完昵称,按下按钮调用
nameUI.SetActive(false);
PhotonNetwork.NickName = playerName.GetComponent<TMP_InputField>().text;
loginUI.SetActive(true);
public void OnClickJoinBtn()//输入完房间名,按下按钮调用
if (roomName.GetComponent<TMP_InputField>().text.Length < 2)
return;
RoomOptions roomoptions = new RoomOptions();
roomoptions.MaxPlayers = 8;
PhotonNetwork.JoinOrCreateRoom(roomName.GetComponent<TMP_InputField>().text, roomoptions, TypedLobby.Default);
public override void OnJoinedRoom()//回调函数,玩家加入房间时调用
base.OnJoinedRoom();
PhotonNetwork.LoadLevel(1);
房间等待界面
房主视角:
其他成员视角:
其他成员准备后
实现的功能
左上角显示房间名,每个玩家上方显示自己的昵称。房主只有开始游戏按钮,其他成员只有准备按钮,其他玩家点击准备,所有房间中的玩家均显示该玩家准备状态,再次点击准备按钮,准备会取消显示。后进入的玩家按顺序入座。当所有玩家均准备时,房主点击开始游戏才会生效,所有玩家进入下一个游戏场景。
这个房间的设计简直是Photon网络框架实现网络同步的入门考试!下面我将详细说明。
部分代码
我会按照我当时完成功能的先后顺序来进行描述,可能会缺少一定的逻辑
生成房间成员到指定位置
PhotonNetwork.Instantiate("Roomer", Roomerloc[i], Quaternion.identity, 0);
这里使用的是Photon的网络生成预制件,需要将预制件存储指定的Resource文件夹中,这样Photon会根据第一参数的字符串找到指定预制件。第二个参数是世界坐标的位置(区别于Canvas中的坐标),后面几个参数没具体了解,好像默认的就行。
使用Photon自带的网络生成的好处是同步方便,一旦生成所有玩家都能看到这个游戏对象。但也有一定的缺点,canvas中利用Grid layout Group可以方便地对生成在其中地游戏对象进行排列,但是Photon网络生成的游戏对象只能在世界坐标下,如何使得Roomer排列整齐成为了第一个困难。
public List<Vector3> Roomerloc = new List<Vector3>();
public void InitRoomloc()
Vector3 v3 = new Vector3(-6, 2, 0);
for (int i = 0; i < 2; i++)
for (int j = 0; j < 4; j++)
Roomerloc.Add(v3);
v3 += new Vector3(4, 0, 0);
v3 = new Vector3(-6, -2, 0);
解决这个问题其实不难,我只需要利用简单的循环计算出适合Roomer放置的位置,存储在Roomerloc当中。接下来需要解决新的问题,如何将Roomer生成到我们想要的位置,这里我想到了一个比较容易地方法:设置一个bool型的数组is_sit来标记哪些位置已经被占用了,后加入房间的玩家只需要寻找一个没有被占座的座位坐下来即可。
这个问题说实话困扰了我几天的时间,原来“欠下的还是要换的”。之前使用网络生成轻而易举地解决了生成同步的问题,但是这个入座问题还是回到了同步问题上去。最初的尝试的结果都是is_sit中数据未能同步,导致几名Roomer坐到同一个位置的情况。
如何实现同步
RPC方式
gameobject.GetComponent<PhotonView>().RPC("UpdateRoomInfo", RpcTarget.MasterClient,传递参数);
需要传递参数的脚本需要挂载Photonview脚本,使用RPC(Remote Procedure Call)进行数据传输,第一参数是函数名称,这些函数之前需要[PunRPC]进行标记,RPC才能找到他们。第二参数是发送数据的对象,RpcTarget.All是房间中所有对象(包括自己),RpcTarget.MasterClient只发送给主机。第三个参数传递参数可以是基本数据类型(int,int[]等)但不能是Gameobject对象。传递参数与调用的函数中的形参需要对应。
SetCustomProperty方式
//设置玩家的属性
ExitGames.Client.Photon.Hashtable table = new ExitGames.Client.Photon.Hashtable();
table.Add("IsReady", false);
PhotonNetwork.LocalPlayer.SetCustomProperties(table);
//获取玩家的属性
object isready;
if (player.CustomProperties.TryGetValue("IsReady", out isready))
//使用isready需要强制转换
我们可以来设置玩家属性,Photon自动会帮我们同步到其他主机之上(只不过速度真的很慢),根据一些经验,一个客户端只能设置自己的property,设置其他玩家的property是不会同步的。
知道了同步的工具还不够,因为一个新玩家加入到房间当中并不是将自己数据告诉其他人,而是请求其他人把数据(is_sit)同步给自己。所以在游戏对象的Start方法中,用RPC向主机请求is_sit数据,主机向所有人发送is_sit数据。对于is_sit的修改只需要用RPC修改主机中的数据即可。简而言之主机来确保is_sit的数据一致。
初始进入房间的同步
现在玩家生成到指定的位置,刚加入的玩家确实能看到前面有几个和他自己一样的“Roomer”预制件,他们是谁,他们的准备状态如何?这些又该如何显示呢?
//显示名字
if (photonView.IsMine)
SetRoomerName(PhotonNetwork.NickName);
else
SetRoomerName(photonView.Owner.NickName);
//显示准备状态
if (photonView.Owner.IsMasterClient)
roomhostimage.SetActive(true);
object isready;
if (photonView.Owner.CustomProperties.TryGetValue("IsReady", out isready))
SetReadyImage((bool)isready);
这是写在每个Roomer在唤醒时所调用的代码,虽然“生成“在一个终端只有一行代码,但是他的生成在所有终端都会调用,也就是说所有终端都会调用每个Roomer生成代码。显示名字的这几行if/else言简意赅但表达的意思很多。如果一个客户机生成自己创建的Roomer,就会执行if中的代码,获取全局变量PhotonView中的NickName,因为这个变量就是当前操作的玩家。如果一个客户机生成其他的Roomer就会执行else中的代码。其中photonview是每个roomer各自的,也就是获取创建Roomer中的玩家的NickName。准备状态的显示也是同理,当然前提需要我们在点击准备时,时刻修改property玩家的准备信息。
游戏主界面
这是我们游戏中最为核心的部分,也会是篇幅最长的一个部分。我将游戏中的内容大致分为四个板块:
1,初始化
2,玩家移动
3,卡牌制作
4,玩家对战
可以简单看一下这个游戏设计得界面,类似于飞行棋,玩家在移动阶段时可以在棋盘上自由移动,之后可以按照一定规则对棋盘上的其他玩家使用牌或者释放技能
初始化
初始化工作我在写代码之初并没有仔细考虑过,但是随着功能的增加,我发现必须要用初始化来做一些游戏进行必要的准备工作,尤其在联机的情况下。并且这些初始化工作也是必须按照一定的先后顺序执行,我将逐一介绍初始化我做了哪些准备工作。
Gamer的生成
//Networkmanager中的start中生成
gamer = PhotonNetwork.Instantiate("Gamer", Vector3.zero, Quaternion.identity, 0);
//Gamer中的Start中执行
if (photonView.IsMine)
ownid = PhotonNetwork.LocalPlayer.ActorNumber;
else
ownid = photonView.Owner.ActorNumber;
Networkmanager网络管理每个玩家都拥有有且仅有一个,用于管理客户端通信之间的代码。Gamer是网络生成的“玩家”,是在世界坐标系下的一个游戏对象。我知道photon为每个房间中的玩家创建了Player类的实例化对象 ,而我最终生成的“棋子”是在canvas当中。为了能使得他们Player和棋子产生联系,我考虑使用Gamer这个中间量。通过用Gamer中的变量指向操作的棋子,来建立操作者和他们操作的棋子之间的联系。
你如果能看到这里,关于ownid想必应该不陌生,我看大佬用这个变量记录玩家的唯一标识符,也就是可以通过Gamer.ownid简单判断是否属于某个Player。我也就依葫芦画瓢,也确实对于后面的代码非常有帮助。
Unity 框架QFramework v1.0 使用指南 工具篇:05. ResKit 资源管理&开发解决方案 | Unity 游戏框架 | Unity 游戏开发 | Unity 独立游戏
Res Kit 简介
Res Kit,是资源管理&快速开发解决方案
特性如下:
- 可以使用一个 API 从 dataPath、Resources、StreammingAssetPath、PersistentDataPath、网络等地方加载资源。
- 基于引用计数,简化资源加载和卸载。
- 拥抱游戏开发流程中的不同阶段
- 开发阶段不用打 AB 直接从 dataPath 加载。
- 测试阶段支持只需打一次 AB 即可。
- 可选择生成资源名常量代码,减少拼写错误。
- 异步加载队列支持
- 对于 AssetBundle 资源,可以只通过资源名而不是 AssetBundle 名 + 资源名 加载资源,简化 API 使用。
Res Kit 快速入门
我们知道,在一般情况下,有两种方式可以让我们实现动态加载资源:
- Resources
- AssetBundle
在 Res Kit 中,推荐使用 AssetBundle 的方式进行加载,因为 Res Kit 所封装的 AssetBundle 方式,比 Resources 的方式更好用。
除了 Res Kit 中的 AsseBundle 方式更易用外,AssetBundle 本身相比 Resources 有更多的优点,比如更小的包体,支持热更等。
废话不多说,我们看下 Res Kit 的基本使用。
Res Kit 在开发阶段,分为两步。
- 标记资源
- 写代码
在开始之前,我们要确保,当前的 Res Kit 环境为模拟模式。
按下快捷键 ctrl + e 或者 ctrl + shift + r ,我们可以看到如下面板:
确保模拟模式勾选之后,我们就可以进入使用流程了。
1. 资源标记
在 Asset 目录下,只需对需要标记的文件或文件夹右键->@ResKit- AssetBundle Mark,如下所示:
标记完了,
标记成功后,我们可以看到如下结果:
- 该资源标记的选项为勾选状态
- 该资源的 AssetLabel 中的名字如下
这样就标记成功了。
这里注意,一次标记就是一个 AssetBundle,如果想要让 AssetBundle 包含多个资源,可以将多个资源放到一个文件夹中,然后标记文件夹。
2.资源加载
接下来我们直接写资源加载的代码即可,代码如下,具体的代码含义,看注释即可。。
using UnityEngine;
namespace QFramework.Example
public class ResKitExample : MonoBehaviour
// 每个脚本都需要
private ResLoader mResLoader = ResLoader.Allocate();
private void Start()
// 项目启动只调用一次即可
ResKit.Init();
// 通过资源名 + 类型搜索并加载资源(更方便)
var prefab = mResLoader.LoadSync<GameObject>("AssetObj");
var gameObj = Instantiate(prefab);
gameObj.name = "这是使用通过 AssetName 加载的对象";
// 通过 AssetBundleName 和 资源名搜索并加载资源(更精确)
prefab = mResLoader.LoadSync<GameObject>("assetobj_prefab", "AssetObj");
gameObj = Instantiate(prefab);
gameObj.name = "这是使用通过 AssetName 和 AssetBundle 加载的对象";
private void OnDestroy()
// 释放所有本脚本加载过的资源
// 释放只是释放资源的引用
// 当资源的引用数量为 0 时,会进行真正的资源卸载操作
mResLoader.Recycle2Cache();
mResLoader = null;
将此脚本挂到任意 GameObject 上,运行后,结果如下:
资源加载成功。
模拟模式与非模拟模式
AssetBundle 的不便之处
在使用 Res Kit 之前,相信大家多多少少接触过 AssetBundle。 有的童鞋可能是在项目中用过 AssetBundle,有的童鞋可能只是简单学习过 AssetBundle。总之,AssetBundle 在不通过 Res Kit 使用之前,总结下来就两个字:麻烦。
AssetBundle 麻烦在哪里呢?
首先 AssetBundle,需要打包才能在运行时加载资源。而打包需要我们写编辑器扩展脚本,在编辑器扩展脚本中还要处理平台和路径相关的逻辑。
在运行时,还需要根据平台和路径去加载对应的 AssetBundle。
这些操作想想就比较头痛。
既然 AssetBundle 这么麻烦,我们为什么还要用 AssetBundle 呢?
因为 AssetBundle 可以给项目带来更好的性能,而且 AssetBundle 支持热更新。
有了这两个优势,AssetBundle 就成了很多项目的必然选择。
而 Res Kit 中,为了解决频繁打包的问题,引入了一个概念:模拟模式(Simulation Mode)。
模拟模式(Simulation Mode)
什么是模拟模式?
顾名思义,就是模拟加载 AssetBundle 的模式,这里只是模拟,并没有真正去加载 AssetBundle,而是去加载 Application.dataPath 目录下的资源,也就是 Assets 目录下的资源。
这样做有什么好处呢?
好处就是每当有资源修改的时候,就不用再打 AB 包了,就可以在运行时加载到修改后的资源。
如果是非模拟模式下,每当有资源修改时,就需要再打一次 AB 包,才能加载到修改后的资源。
所以一个模拟模式,解决了频繁打 AB 包的问题,从而在开发阶段提高我们的开发效率。
那么在使用 Res Kit 的时候,模拟模式对应的阶段是开发阶段,那么非模拟模式对应的是什么阶段呢?
答案就是真机阶段。
开发阶段、真机阶段
开发阶段、真机阶段并不是 Unity 提供的概念,而是笔者在迭代 Res Kit 中提出的两个概念。
这两个概念很容易理解:
- 开发阶段:开发逻辑的阶段,需要编写大量的逻辑,大部分情况下都在 Unity Editor 环境下开发。
- 真机阶段:需要在真机上运行的阶段,这个阶段主要是做大量的测试或者真正发布了。
相信有点规模的项目都会分阶段出来的,比如开发阶段、测试阶段、生产阶段等等,大家理解起来应该不难。
接下来简单分析一下开发阶段、真机阶段的特点。
开发阶段
在开发阶段,开发者需要写大量的逻辑,而且资源的目录还没有稳定,一般在开发过程中会有很大的变化。
如果每次资源的修改都需要打 AB 包的话,会非常影响开发进度。
真机阶段
真机阶段,一般就是一个版本的逻辑都写完了,只需要做一些测试和 debug 工作。在这个阶段,资源目录都稳定了,不需要做很大的调整。
在真机阶段,每次打 App 包之前,只需要 Build 一次 AB 即可。
当然,在 Unity Editor 环境中,可以取消勾选模拟模式,这样在 Unity Editor 环境下可以加载真正的 AssetBundle 包。
在上一篇文章所说的,拥抱各个开发阶段指的就是为开发阶段、和真机阶段做了考虑。
此篇的内容就这些。
小结
- 开发阶段:
- 模拟模式
- 真机阶段:
- 每次打 App 包之前,打一次 AB 包。
- 可以在 Unity Editor 环境下,取消勾选模拟模式,这时在运行时加载的资源则是真正的 AssetBundle 资源
如何打 AssetBundle(真机模式)
取消勾选模拟模式情况下,点击打 AB 包 即可。
异步加载
异步加载代码如下:
// 添加到加载队列
mResLoader.Add2Load("TestObj",(succeed,res)=>
if (succeed)
res.Asset.As<GameObject>()
.Instantiate();
);
// 执行异步加载
mResLoader.LoadAsync();
与 LoadSync 不同的是,异步加载是分两步的,第一步是添加到加载队列,第二步是执行异步加载。
这样做是为了支持同时异步加载多个资源的。
异步加载
代码如下:
using System.Collections;
using UnityEngine;
namespace QFramework.Example
public class AsyncLoadExample : MonoBehaviour
IEnumerator Start()
yield return ResKit.InitAsync();
var resLoader = ResLoader.Allocate();
resLoader.Add2Load<GameObject>("AssetObj 1",(b, res) =>
if (b)
res.Asset.As<GameObject>().Instantiate();
);
// AssetBundleName + AssetName
resLoader.Add2Load<GameObject>("assetobj 2_prefab","AssetObj 2",(b, res) =>
if (b)
res.Asset.As<GameObject>().Instantiate();
);
resLoader.Add2Load<GameObject>("AssetObj 3",(b, res) =>
if (b)
res.Asset.As<GameObject>().Instantiate();
);
resLoader.LoadAsync(() =>
// 加载成功 5 秒后回收
ActionKit.Delay(5.0f, () =>
resLoader.Recycle2Cache();
).Start(this);
);
结果如下:
加载场景
注意:标记场景时要确保,一个场景是一个 AssetBundle。
using UnityEngine;
namespace QFramework.Example
public class LoadSceneExample : MonoBehaviour
private ResLoader mResLoader = null;
void Start()
ResKit.Init();
mResLoader = ResLoader.Allocate();
// 同步加载
mResLoader.LoadSceneSync("SceneRes");
// 异步加载
mResLoader.LoadSceneAsync("SceneRes");
// 异步加载
mResLoader.LoadSceneAsync("SceneRes", onStartLoading: operation =>
// 做一些加载操作
);
private void OnDestroy()
mResLoader.Recycle2Cache();
mResLoader = null;
加载 Resources 中的资源
using UnityEngine;
using UnityEngine.UI;
namespace QFramework.Example
public class LoadResourcesResExample : MonoBehaviour
public RawImage RawImage;
private ResLoader mResLoader = ResLoader.Allocate();
private void Start()
// 加载 Resources 目录里的资源不用调用 ResKit.Init
RawImage.texture = mResLoader.LoadSync<Texture2D>("resources://TestTexture");
private void OnDestroy()
Debug.Log("On Destroy ");
mResLoader.Recycle2Cache();
mResLoader = null;
关联对象管理
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
namespace QFramework.Example
public class ResLoaderRelateUnloadAssetExample : MonoBehaviour
// Use this for initialization
IEnumerator Start()
var image = transform.Find("Image").GetComponent<Image>();
ResKit.Init();
var resLoader = ResLoader.Allocate();
var texture2D = resLoader.LoadSync<Texture2D>("TextureExample1");
// create Sprite 扩展
var sprite = Sprite.Create(texture2D, new Rect(0, 0, texture2D.width, texture2D.height), Vector2.one * 0.5f);
image.sprite = sprite;
// 添加关联的 Sprite
resLoader.AddObjectForDestroyWhenRecycle2Cache(sprite);
yield return new WaitForSeconds(5.0f);
// 当释放时 sprite 也会销毁
resLoader.Recycle2Cache();
resLoader = null;
SpriteAtlas 加载
using System.Collections;
using UnityEngine;
using UnityEngine.U2D;
using UnityEngine.UI;
namespace QFramework
/// <inheritdoc />
/// <summary>
/// 参考:http://www.cnblogs.com/TheChenLin/p/9763710.html
/// </summary>
public class TestSpriteAtlas : MonoBehaviour
[SerializeField] private Image mImage;
// Use this for initialization
private IEnumerator Start()
var loader = ResLoader.Allocate();
ResKit.Init();
var spriteAtlas = loader.LoadSync<SpriteAtlas>("spriteatlas");
var square = spriteAtlas.GetSprite("shop");
loader.AddObjectForDestroyWhenRecycle2Cache(square);
mImage.sprite = square;
yield return new WaitForSeconds(5.0f);
loader.Recycle2Cache();
loader = null;
加载网络图片
using UnityEngine;
using UnityEngine.UI;
namespace QFramework.Example
public class NetImageExample : MonoBehaviour
ResLoader mResLoader = ResLoader.Allocate();
// Use this for initialization
void Start()
var image = transform.Find("Image").GetComponent<Image>();
mResLoader.Add2Load<Texture2D>(
"http://pic.616pic.com/ys_b_img/00/44/76/IUJ3YQSjx1.jpg".ToNetImageResName(),
(b, res) =>
if (b)
var texture = res.Asset as Texture2D;
var sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height),
Vector2.one * 0.5f);
image.sprite = sprite;
mResLoader.AddObjectForDestroyWhenRecycle2Cache(sprite);
);
mResLoader.LoadAsync();
private void OnDestroy()
mResLoader.Recycle2Cache();
mResLoader = null;
从 PersistentDataPath 加载图片
namespace QFramework.Example
using System.Collections;
using UnityEngine.UI;
using UnityEngine;
public class ImageLoaderExample : MonoBehaviour
private ResLoader mResLoader = null;
private IEnumerator Start()
ResMgr.Init();
mResLoader = ResLoader.Allocate();
// local image
var localImageUrl = "file://" + Application.persistentDataPath + "/Workspaces/lM1wmsLQtfzRQc6fsdEU.jpg";
mResLoader.Add2Load(localImageUrl.ToLocalImageResName(),
delegate(bool b, IRes res)
Debug.LogError(b);
if (b)
var texture2D = res.Asset as Texture2D;
transform.Find("Image").GetComponent<Image>().sprite = Sprite.Create(texture2D,
new Rect(0, 0, texture2D.width, texture2D.height), Vector2.one * 0.5f);
);
mResLoader.LoadAsync();
yield return new WaitForSeconds(5.0f);
mResLoader.Recycle2Cache();
mResLoader = null;
自定义 Res
ResKit 提供了 自定义 Res ,通过自定义 Res 可以非常方便地自定义 Res 的加载来源,比如 PersistentDataPath、StreamingAssetPath、AssetBundle 等,甚至是内存中的 GameObject 等资产,还可以集成 Addressables 或者其他的资源管理方案,ResKit 内置支持的 AssetBundle、Resources、网络图片加载、PersistentDataPath 图片加载都是通过自定义 Res 的方式扩展而来。
我们看下自定义 Res 的用法,如下:
using UnityEngine;
namespace QFramework
public class CustomResExample : MonoBehaviour
// 自定义的 Res
public class MyRes : Res
public MyRes(string name)
mAssetName = name;
// 同步加载(自己实现)
public override bool LoadSync()
// Asset = 加载的结果给 Asset 赋值
State = ResState.Ready;
return true;
// 异步加载(自己实现)
public override void LoadAsync()
// Asset = 加载的结果给 Asset 赋值
State = ResState.Ready;
// 释放资源(自己实现)
protected override void OnReleaseRes()
// 卸载操作
// Asset = null
State = ResState.Waiting;
// 自定义的 Res 创建器(包含识别功能)
public class MyResCreator : IResCreator
// 识别
public bool Match(ResSearchKeys resSearchKeys)
return resSearchKeys.AssetName.StartsWith("myres://");
// 创建
public IRes Create(ResSearchKeys resSearchKeys)
return new MyRes(resSearchKeys.AssetName);
void Start()
// 添加创建器
ResFactory.AddResCreator<MyResCreator>();
var resLoader = ResLoader.Allocate();
var resSearchKeys = ResSearchKeys.Allocate("myres://hello_world");
var myRes = resLoader.LoadResSync(resSearchKeys);
resSearchKeys.Recycle2Cache();
Debug.Log<以上是关于Unity独立开发游戏之路的主要内容,如果未能解决你的问题,请参考以下文章
Unity 框架QFramework v1.0 使用指南 工具篇:05. ResKit 资源管理&开发解决方案 | Unity 游戏框架 | Unity 游戏开发 | Unity 独立游戏
Unity 框架QFramework v1.0 使用指南 架构篇:05. 引入 Utility | Unity 游戏框架 | Unity 游戏开发 | Unity 独立游戏
Unity 框架QFramework v1.0 使用指南 架构篇:20. QFramework.cs 的更多内容 | Unity 游戏框架 | Unity 游戏开发 | Unity 独立游戏
Unity 框架QFramework v1.0 使用指南 架构篇:19. 心中有架构 | Unity 游戏框架 | Unity 游戏开发 | Unity 独立游戏