Unity脚本笔记
脚本里的类和脚本重名就报错,不重名反而没问题。原因:我已经定义了这个名字的类,但是我忘了。typeof(类名);实例.GetType();Type.GetType("命名空间.类名");
Debug DrawRay()看不到射线
原因:Gizmos没开。

Screen.width/Screen.height得到0
打印屏幕的长宽比:

输出是0:

原因:这两个值是int,要计算它们的比需要转换成float。
在脚本里判断一个引用的对象是在Scene里的实例还是预制体
脚本里开放的对象引用在Inspector里显示为接口,可以把Scene里的物体实例拖进去,也可以把Assets里的预制体拖进去,但是预制体不实例化是不能用的。脚本需要能判断它是实例还预制体。



判断gameObject.scene.name==null,Assets里的预制体这个值是Null,场景里的是场景的名字。

Invoke()
只能调用没有参数的方法,即使有默认值的参数也不行。
UnityEvent
UnityEvent引用在Inspector里是这样的:

可以在这个列表里添加某对象的某方法,列表里的方法叫“监听器”(Listener)。手拖进去的叫持久性监听器。takeAction.AddListener()可以在脚本里添加监听,叫非持久性监听器,不显示在Inspector里。
执行takeAction.Invoke()会执行这个列表里的所有方法。

背包系统关于List Remove()的报错
这个错误是测试背包系统,拿起物品时出现的,是偶发的。点击可拾取物品里的物品时,会从背包的可拾取物品List里删除相应的元素,此时有概率报错:ObjectDisposedException: SerializedProperty itemsInReach.Array.data[3] has disappeared!



但是可拾取物品的List的元素被正常删掉了:

拿起最后一个物品时有概率报错:InvalidOperationException: The operation is not possible when moved past all properties (Next returned false)。此时最后一个物品在可拾取物品List里的元素不会被删掉,它指向的物品已经删掉了,所以这个元素变成一个空引用。


新发现:测试的时候如果Inspector没有看着背包脚本或背包脚本折叠起来就从来不会报这个错。我又看到报错是关于UnityEditor的,应该是脚本的接口更新显示相关的报错。不是我的脚本的错误,所以这个报错我不打算管了。

定义一个脚本后报错The namespace '<global namespace.' already contains a definition for 'xxx'
脚本里的类和脚本重名就报错,不重名反而没问题。

原因:我已经定义了这个名字的类,但是我忘了。
写了ContextMenu,但是Inspector脚本的菜单里没有

原因:加ContextMenu的方法都不能有参数!否则不出现!
NotSupportedException: Specified method is not supported.
这个报错可能对应不止一种具体错误。。

我出问题的那一行是这样:

经过检查是因为这个Resources.Load()路径的资源不存在。但是报的这个错我完全看不懂。我决定不在静态类里用字符串记录预制体路径了,还是往检查器里拖。
Destroy()和DestroyImmediate()
Destroy()是把物体放在一个缓冲区,下一帧删除,所以只能在Play Mode使用,在编辑状态要使用DestroyImmediate()。因为这个区别,删除所有子物体时GetChild()的效果不一样,Play Mode在一个方法里执行Destroy()前后GetChild()返回的值没有变化,可以用GetChild(i),

编辑模式执行DestroyImmediate()后后面的子物体向前进一位,循环删除应该用GetChild(0).

充分利用动画状态机的逻辑
这个问题在射击游戏笔记里说过,一些代码的执行时机其实刚好就是动画状态机的一些状态切换时。
比如人物跑步、换弹、趴下、站起时不允许瞄准、射击,与其在玩家输入这些动作时判断,其实这些刚好就是人物离开持枪状态的去向,直接写在持枪状态的OnStateExit()即可。
再比如更新枪和子弹的信息,需要执行的时机有很多:从身上取枪时、交换枪时、拿起手里枪对应的弹匣时、换弹时、射击时、放下手里的枪时、放下手里枪的弹匣时、改变自动方式时。其中除了射击、改变自动方式,其他要更新的时机都不在持枪状态,那么就可以写在持枪状态的OnStateEnter()。

用lambda表达式给按钮传递带参数的回调方法onClick.AddListener(()=>...)
如果lambda表达式用到了循环变量,一定要临时声明一个局部变量记录它。否则lambda表达式将使用循环结束后的循环变量。

rigidBody.velocity和animator.velocity的关系
二者共同决定了物体的速度,二者没什么关系。使用animator的Root Motion驱动角色移动时,刚体的velocity是0,除了角色下坡时,刚体的重力起作用,会有一点向下的速度;角色上坡被地面顶向上时,刚体并没有向上的速度。
动画事件方法和有参数方法的矛盾
动画事件方法不能有参数,但是想让一个方法既当动画事件又被其他地方调用,且其他地方有参数会很方便。
可以写一个无参的方法作动画事件,它调用有参的方法,参数写死。
void GrabRifleAnimEvent(){//动画事件里要用这个方法,没法传自定义类型参数
MoveGunInHand(rifleScript);
}
void GrabPistolAnimEvent(){
MoveGunInHand(pistolScript);
}
void MoveGunInHand(Item item){
item.transform.SetParent(rightHand);
item.transform.localPosition=Vector3.zero;
item.transform.localEulerAngles=gunEulerInHand;
}
特性相关方法
查询有没有特性:
type.IsDefined(typeof(自定义特性类型),bool 是否查找继承链)
得到特性类:
type.GetCustomAttributes(bool 是否查找继承链)
异步加载场景在Hierarchy出现好几个场景名(Is loading)

代码:
public Slider loadSceneProgress;
void Start()
{
DontDestroyOnLoad(gameObject);
// DontDestroyOnLoad(loadSceneProgress.gameObject);
}
public string sceneName;
AsyncOperation asyncOperation=null;
public void StartLoadAsync(){
asyncOperation=SceneManager.LoadSceneAsync(sceneName);
StartCoroutine(MyLoadSceneAsync(null));
}
IEnumerator MyLoadSceneAsync(UnityAction Preset){
while(!asyncOperation.isDone){
loadSceneProgress.value=asyncOperation.progress;
Debug.Log(asyncOperation.progress);
yield return null;
}
loadSceneProgress.value=1;
Preset();
}
void OnTriggerEnter(){
Instance.StartLoadAsync();
}
}
原因:异步加载是通过OnTriggerEnter()触发的,加载中每一帧都执行一次OnTriggerEnter()。
缓冲池
看的教程里缓冲池包含一个字典,键是字符串,值是存放缓冲物体的父物体。
Dictionary<string,GameObject> bufferDict=new Dictionary<string,GameObject>();
觉得这样写字符串容易打错,不如用枚举。
延迟放入缓冲池使用协程,yield return new WaitForSeconds()后面没有执行
public IEnumerator EnpoolLater(GameObject instance,BufferType bufferType,float delay){
yield return new WaitForSeconds(delay);
if(bufferType==BufferType.impact){
Debug.Log("击中效果进入");
}
Enpool(instance,bufferType);
}
原因:放入的是击中效果,由子弹触发,但是子弹在执行Enpool()前被销毁了。又把子弹的销毁改成不活动,也不能执行延迟入池。
所以延迟入池的代码还是要放在被入池物体的OnEnable()里,不要让其他物体触发。其他物体可能提前就被销毁或失活了。
射击游戏弹头缓冲池的问题
射击游戏的弹头、击中效果、弹壳都使用缓冲池存储,出现了击中效果不在瞄准位置的问题。
[RequireComponent(typeof(CapsuleCollider))]
[RequireComponent(typeof(Rigidbody))]
public class MyBullet : MonoBehaviour
{
public LayerMask bulletLayerMask;
int groundLayer=8;
public WeaponData weaponDamageData;
Vector3 lastFramePosition;
float lifeTime=1;
void Start(){
}
void OnEnable(){
lastFramePosition = transform.position;
StartCoroutine(BufferPoolBase.Instance.EnpoolLater(gameObject,BufferPoolBase.BufferType.bullet,lifeTime));
}
RaycastHit raycastHit;
BodyTrigger bodyTrigger;
ImpactEffectRecorder myImpactEffect;
void Update(){
if(Physics.Linecast(lastFramePosition,transform.position,out raycastHit,bulletLayerMask,QueryTriggerInteraction.UseGlobal)){
if(raycastHit.collider.gameObject.layer==groundLayer){//打到地
if(raycastHit.transform.TryGetComponent(out myImpactEffect)){
GameObject effectInstance;
effectInstance=BufferPoolBase.Instance.Depool(myImpactEffect.impactEffectPrefab.gameObject,BufferPoolBase.BufferType.impact);//缓冲池出池
effectInstance.transform.position=raycastHit.point;
effectInstance.transform.LookAt(raycastHit.point+raycastHit.normal);
}
else{
Debug.Log(raycastHit.transform.name+"没有击中效果!");
}
}
else{
if(raycastHit.collider.TryGetComponent(out bodyTrigger)){
bodyTrigger.GetHurt(weaponDamageData);
}
}
BufferPoolBase.Instance.Enpool(gameObject,BufferPoolBase.BufferType.bullet);
}
}
}
弹头脚本有一个Vector3记录上一帧的位置,每一帧的位置和上一帧的位置使用Physics.Linecast()检测击中。在OnEnable()里记录了一次当前位置,但是这个代码是在把弹头放到枪口之前的,导致击中检测的连线是从上一次子弹入池的位置到枪口。
解决方法:给Depool()加一个参数Vector3 position,先设置位置再激活。理论上所有物体都应该这样,但是其他物体影响不大,弹头必须这样。修改后的Depool():
Dictionary<BufferType,Transform> bufferDict=new Dictionary<BufferType,Transform>();
//可能的情况包括没缓冲池、有缓冲池没物体(一般不会有,但理论上可能)、有缓冲池有物体
public GameObject Depool(GameObject prefab,BufferType bufferType,Vector3 position){
GameObject instance;
if(bufferDict.ContainsKey(bufferType)){//缓冲池已建立
if(bufferDict[bufferType].childCount>0){//缓冲池里有物体
instance=bufferDict[bufferType].GetChild(0).gameObject;//取出
instance.transform.SetParent(null);//解绑
}
else{//有缓冲池没物体(曾经放入过物体,又拿出了)
instance=GameObject.Instantiate(prefab);
}
}
else{//没有缓冲池
Transform bufferTransform=new GameObject(bufferType.ToString()).transform;
bufferDict.Add(bufferType, bufferTransform);
instance=GameObject.Instantiate(prefab);
}
instance.transform.position = position;
instance.SetActive(true);//激活
return instance;
}
这样写第一个弹头还是有问题,第一个弹头的OnEnable()在Instantiate()时执行,在设置位置之前,导致记录的上是帧位置是(0,0,0)。所以又在需要Instantiate()的参数里加上了position和一个Quaternion.identity。
总结:缓冲池的严谨写法是在SetActive()之前就设置好位置,如果是实例化,则在Instantiate()参数里加上位置。对大部分物体这可能只是锦上添花,但是对弹头实体,需要记录上一帧位置,通过Physics.Linecast()判断击中时,必须这样写。
NullReferenceException: SerializedObject of SerializedProperty has been Disposed.
背景:写了两个带[ExecuteAlways]的脚本,开始运行后无限报错,停止运行也报错。把一个脚本删掉,执行,不报错,再撤销删除脚本,再执行,也不报错。


DontDestroyOnLoad的单例物体在返回一个场景时怎么防止出现两个
这是一个跨场景的单例,可以在Start()里判断:
1.单例是空,把自己写入;
2.单例非空&&单例不是自己,把自己销毁;
3.它不能继承泛型单例基类,因为判断单例为空不能使用Instance,否则就像其他类调用它一样触发“要么新建要么报错”的自动操作,需要使用instance,那么这个私有instance必须写在自己里;
public class MySceneManager : MonoBehaviour
{
private static MySceneManager instance;
public static MySceneManager Instance{
get{
if(!instance){
#if UNITY_EDITOR
Debug.Log($"场景里没有MySceneManager实例!");
if (UnityEditor.EditorApplication.isPlaying)
{
UnityEditor.EditorApplication.isPlaying = false;
}
#endif
}
return instance;
}
}
void Start()
{
if(!instance){
instance=this;
}
else if(Instance!=this){
Destroy(gameObject);
}
DontDestroyOnLoad(gameObject);
}
}
异步加载场景从第二个场景回第一个场景后IEnumerator的一些代码没有执行,且新场景的动画没有播放,物理引擎无效
原因:从游戏场景加载回来是经过了暂停界面,执行了Time.timeScale=0;没复原。
为什么只有匿名函数、lambda表达式会出现闭包
匿名函数、lambda表达式支持在一个函数内定义,在这个函数外执行。而C#虽然允许函数内定义函数,却不允许函数内定义,函数外执行。委托装匿名函数、lambda表达式是个例外。
玩家人物对界面的修改代码应该放在哪里
游戏界面有武器信息、交互动作等信息。玩家人物在某些情况下会修改这些信息,修改的代码可以防止人物脚本里(游戏里不管是不是玩家人物都有人物脚本),人物脚本知道该修改信息的时间,但不知道这个人物是不是玩家需要判断一下;也可以放在某个管理器单例里,这样不用判断某个人物是不是玩家,但又不知道该修改信息的时间,只能在Update()里一直监视。

通过反射判断数据类型使用相应的PlayerPrefs读取数据失败
object LoadValue(Type fieldType,string keyName){
if(fieldType==typeof(int)){
return PlayerPrefs.GetInt(keyName);
}else if(fieldType==typeof(float)){
return PlayerPrefs.GetFloat(keyName);
}else if(fieldType==typeof(string)){
return PlayerPrefs.GetString(keyName);
}else if(fieldType==typeof(bool)){
return PlayerPrefs.GetString(keyName)=="T"?true:false;
}
return null;
}
原因:调用上面的函数时传入了fieldInfo.GetType(),应该传fieldInfo.FieldType。
static readonly的坑
static readonly会在构造函数之后执行。如果构造函数里用到了,应该改用const。
public class GameDataManager
{
static GameDataManager instance=new GameDataManager();
public static GameDataManager Instance{get=>instance;}
static readonly string audioDataKey="AudioData";
public AudioData audioData;
GameDataManager(){
audioData=PlayerPrefsDataManager.Instance.Load(typeof(AudioData),audioDataKey) as AudioData;
if((!audioData.notFirst)){
audioData.notFirst=true;
audioData.soundVolume=1;
audioData.musicVolume=1;
audioData.musicOn=true;
audioData.soundOn=true;
PlayerPrefsDataManager.Instance.Save(audioData,audioDataKey);
Debug.Log(audioDataKey);
}
}
让编辑器停止播放
#if UNITY_EDITOR
if (UnityEditor.EditorApplication.isPlaying){
UnityEditor.EditorApplication.isPlaying = false;
}
#endif
List自带的排序方法的输入方法怎么记
返回1代表排后面,-1代表排前面。
rankData.Sort((a,b)=>{
if(a.passTime>b.passTime){
return 1;
}
return -1;
});
ScriptableObject实例检查器显示Script是None

原因:这个脚本里写了两个类,一个继承ScriptableObject,还有另一个。但是脚本名字和继承ScriptableObject的类不同名。
代码刷新Asset文件夹
AssetDataBase.Refresh();
玩家重生时把玩家摆到重生位置的代码没有生效
public void RespawnPlayer(){
// firstPersonCam.transform.parent=null;
// aimCam.transform.parent=null;
Cursor.lockState=CursorLockMode.Locked;
player.transform.position=respawnPosition.position;
player.transform.eulerAngles=new Vector3(0,player.transform.eulerAngles.y,0);
if(player is Character1){
(player as Character1).rigidbody=player.GetComponent<Rigidbody>();
(player as Character1).rigidbody.constraints=
RigidbodyConstraints.FreezeRotationY|
RigidbodyConstraints.FreezeRotationZ|
RigidbodyConstraints.FreezeRotationX;
}
player.animator.Rebind();
if(playerInitialLife<=0){
playerInitialLife=100;
}
player.life=playerInitialLife;
PanelGame.Instance.UpdateLifeBar(player.life);
InteractionManagerOverlap.Instance.Enable();
}
public void Revive(){//游戏结束界面按重生执行的
MyGameManager.Instance.RespawnPlayer();
Destroy(gameObject);
}
把RespawnPlayer用ContextMenu开放出来执行有效。把重生按钮的回调改成只修改玩家位置,也无效。人物死后把CharacterController移除,再点重生,问题没有了。又是这玩意搞的鬼。所以发现有CharacterController时,通过UGUI按钮回调改变人物位置会无效。
脚本里开放的UnityEvent字段后来删除了但是还留在检查器面板?

类的静态变量写入后,没有读取一下直接用用的就是默认值?
登录时写入了当前用户名。读取数据时还是打印读取admin的数据。写入用户名时打印一下,然后就读取登录的用户的数据了?
public static string nowUser="admin";
DataManager.nowUser=inputFieldName.text;
// Debug.Log(DataManager.nowUser);
DataManager.Instance.ReadUserData();
public void ReadUserData(){
#if UNITY_EDITOR
Debug.Log(string.Concat("读取用户",nowUser,"的数据"));
#endif
}
编译预处理#define的名字是否跨脚本存在?
在一个脚本#define了一个名字,另一个脚本判断不存在。全局存在的名字需要在Project settings-Player里加:

不继承MonoBehavior的单例在编辑器里什么时候销毁
每次改完脚本回到编辑器重新编译时销毁。
Resources.Load()
输入的路径不能有后缀名。
Instantiate()和Start()的时间关系
在一个函数里实例化一个对象A,这个对象的Start()里实例化一个对象B,同一个函数里后面执行Init(),有对B的身上的Text的写入,报空。实例化后Start()好像在实例化所在的代码块之后执行。总之把Start()的内容也放进Init()吧。
基类的一个虚函数的代码,子类需要的代码大部分和base一样,中间小部分不一样,怎么设计?
可以直接打破虚函数-重写的设计,把子类和基类相同的代码另放一个函数A,基类原来的虚函数叫B,子类重写的叫C,基类不一样的代码是D,子类的是E。
基类:
B(){
D();
A();
}
子类:
C(){
E();
A();
}
代码脚本划分的问题
一些模块的功能需要巨量代码,比如人物,需要移动旋转、动画控制、地面检测、交互检测(拾取、对话、开门、上载具)、拥有的物品管理(拾取、放下、使用)、对武器的管理……
这巨量代码的分布,有3种方案:
- 写在一个脚本,形成一个巨大的脚本;
- 分成不同类,放在不同脚本,给一个人物把这些脚本都挂上;
- 用partial class,类是一个类,代码在不同脚本;
1做人物只用挂一个脚本,加一些自带组件,制作人物预制体比较方便,但是看代码可能要在一个几百上千行的脚本翻找,即使用#region隔开也很难找。2代码是模块化了,但是做一个人物预制体要挂的脚本增加了,有可能忘记挂一些脚本。而且这些类要相互访问,还要定义字段,写GeComponent,类多了还会形成蜘蛛网。3是综合了1、2的利弊的方案。
类的初始化时成员类的初始化问题
对于一个类里的引用型成员,在定义里写上new,可以在new这个类时把里面的引用型成员也都new了,避免报空。
[Serializable]
public class UserPropsData{
public List<int> ownedCharacters=new List<int>();
public int characterIndex;
public List<int> ownedRifles=new List<int>();
public int rifleIndex=-1;
public List<int> ownedHandguns=new List<int>();
public int handgunIndex=-1;
public ItemsInPack inventoryProps=new ItemsInPack();
public ItemsInPack packProps=new ItemsInPack();
public int money;
}
userPropsData=new UserPropsData();
字段初始化值修改后在Unity编辑器的实例无效的问题
字段初始化在Unity里创建场景实例、创建预制体时就会生效,再改初始化值,初始化不会再执行,也不会生效。
检查器的成员class何时为null?
定义一个class A,加上[Serializable],作为一个MonoBehavior的成员,加到游戏对象上,打印A,发现不为空。如果打印前设null,则为空。而且在检查器上看空对象和全部值为默认值的对象是一样的。
[ContextMenu("为空???")]
void P()
{
// otherPack = null;
Debug.Log(otherPack);
Debug.Log(otherPack == null);
}
如果把设null放在另一个函数D,打印放在另一个函数P,先执行D,再执行P,也不为空。只能在打印前一行设null才打印出来空,好像在编辑器里游戏对象上依附的组件的成员类如果为空都会被立即分配对象。
[ContextMenu("设空")]
void D()
{
otherPack = null;
}
[ContextMenu("为空???")]
void P()
{
// otherPack = null;
Debug.Log(otherPack);
Debug.Log(otherPack == null);
}
总之不要通过MonoBehavior成员类是否为空判断任何信息,永远当它不为空。
Debug.Log打印一个实例打出Null和null有什么区别???
背景
我写了一个检查单例如果已经存在,则把正在初始化的单例销毁
protected virtual void Awake(){
Debug.Log(instance);
Debug.Log(instance==null);
if (instance == null)
{
instance = this as T;
}
else if (instance != this as T)
{
#if UNITY_EDITOR
Debug.Log(typeof(T) + "已存在!");
#endif
Destroy(gameObject);
}
}
然后发现打印instance有Null和null,其中打印null时instance==null会判false。这里它把我的任务面板销毁了,其他单例面板都没有销毁。我想知道任务面板单例是怎么变成null的。
退出关卡回到主菜单后打印任务面板单例和HUD面板单例:

这里我能想到的任务面板的特殊就是它有一个协程更新任务目的地标志,在OnDestroy里停止了这个协程。然后我把停止协程的代码放到OnDisable,问题就解决了。
然后发现单例基类写了OnDestroy,里面会把单例置空。
void OnDestroy(){//直接销毁一个单例的父物体时,它也会被销毁,防止出现instance指向已销毁对象
if(instance==this as T){//多余的单例自我销毁时,不能置空单例
instance=null;
}
}
如果子类直接也写OnDestroy,就执行子类的,不会执行这个置空。
我的实验
发现Monobehavior变量在检查器是None时打印Null,是Missing时打印null。可以用来区分变量是被赋值过null(打印Null)还是只把指向的对象销毁了(打印null)。


游戏对象变量是None时也打印null,不管是None还是Missing都打印null。
2025.10.15今天做实验的结论是:一个字段只有开放到检查器后未赋值前打印为Null,赋值后再不管置空还是对象销毁都打印null,总之就是Null很难打印出来。想再看见Null只能把这个字段删掉,重编译,再加上,再重编译。
实现在检查器能配置的多选枚举
1. 定义带Flags特性的枚举
[System.Flags]
public enum MyEnum
{
None = 0,
Option1 = 1 << 0, // 1
Option2 = 1 << 1, // 2
Option3 = 1 << 2, // 4
Option4 = 1 << 3 // 8
}
2. 在Inspector面板显示多选
创建自定义属性绘制器:
using UnityEditor;
using UnityEngine;
public class EnumMultiAttribute : PropertyAttribute { }
[CustomPropertyDrawer(typeof(EnumMultiAttribute))]
public class EnumMultiAttributeDrawer : PropertyDrawer
{
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
property.intValue = EditorGUI.MaskField(position, label, property.intValue, property.enumNames);
}
}
3. 在MonoBehaviour中使用
public class TestScript : MonoBehaviour
{
[EnumMultiAttribute]
public MyEnum myEnum;
// 判断是否选中某个枚举值
public bool IsSelected(MyEnum type)
{
int index = 1 << (int)type;
return ((int)myEnum & index) == index;
}
}
关键要点
-
[Flags]特性:允许枚举进行位运算,支持多选功能1 - 2的幂次赋值:每个枚举值赋予2的幂次数值,避免值冲突1
- 位运算检查:使用按位与运算判断是否选中特定选项
build版本中某个函数的输入参数对象有概率从某一行开始变成null
下面从if (!chasingMission){}后面mission在build版本有概率变成null。这个函数是通过事件中心触发事件执行的,vs附加到进程发现调用时它所属的对象已经是null。
public void OpenMission(MissionBase mission){
if (missionList.Contains(mission))
{
return;
}
missionList.Add(mission);
UpdateMissionList();
ShowMissionOpen(mission);
if (!chasingMission)
{
chasingMission=mission;
}
Debug.Log("任务是" +mission);
Debug.Log("任务对象是" + mission.gameObject);
mission.gameObject.SetActive(true);
missionMarker.gameObject.SetActive(true);
}
原因:它的类写了OnDestroy()方法,覆盖了基类的OnDestroy(),导致移除监听的代码没有执行。第二次进这个关卡时执行的还是上一次关卡添加的监听,它的对象已被销毁。对此我们可以总结一下观察者模式忘记移除监听的问题的特征:对象报空,且不是第一次进行某过程时出现,而是重复进行某过程(一般是重复进入某场景)时出现。
这里是一个专注于游戏开发的社区,我们致力于为广大游戏爱好者提供一个良好的学习和交流平台。我们的专区包含了各大流行引擎的技术博文,涵盖了从入门到进阶的各个阶段,无论你是初学者还是资深开发者,都能在这里找到适合自己的内容。除此之外,我们还会不定期举办游戏开发相关的活动,让大家更好地交流互动。加入我们,一起探索游戏开发的奥秘吧!
更多推荐



所有评论(0)