记录我的Unity开源游戏项目——坦克大战 的学习
该开源项目比较简单,很适合初学者学习,如果想提升自己的代码水平或想了解游戏开发的整体框架,可以学习这个开源项目。
一、使用静态类用于存储游戏中的各种数据
usingUnityEngine;
namespaceConstant
{
/// <summary>
/// 描述:用于管理游戏中的常熟数据
/// </summary>
publicstaticclassGameConst
{
//玩家出生点坐标
publicstaticVector3Player1BornVector3=newVector3(-2, -8, 0);
publicstaticVector3Player2BornVector3=newVector3(2, -8, 0);
//玩家与敌人
publicconststringPlayer1Prefab="Prefabs/Tank/Player1";
publicconststringPlayer2Prefab="Prefabs/Tank/Player2";
}
}
设计之妙:有助于整个项目中保持资源路径和对对象标签的一致性,以及方便地在不同的代码文件中共享这些信息。这个类的作用是提供了一个集中管理游戏中各种资源路径、对象标签、出生点位置等的容器,方便整个项目的使用和维护。
缺点:如果游戏中的常量非常多,这个静态类会变的非常大和难以维护。这时候应该考虑将它们分组或者按功能模块分开定义在不同的静态类中,又或者使用Unity中的ScriptableObject,可以将常量定义在一个可配置的、可视化的对象中,方便管理和修改。
在开源项目中发现的一些bug
在游戏移动时的音频切换有问题
if (Math.Abs(h) >0 )
{
varaudioClip=Resources.Load<AudioClip>(GameConst.DrivingAudio);
tankAudio.Stop();
tankAudio.clip=audioClip;
if (!tankAudio.isPlaying)
{
isPlayAudio=true;
AudioSource.PlayClipAtPoint(audioClip,transform.position);
}
return;
}
问题分析:在横向移动时 h 的值是在变化的,条件会一直成立,这也就导致它会一直加载新的音效进行播放,导致音效鬼畜。
解决办法::
创建一个bool类型的变量记录它是否是移动的状态,如果是移动的状态且 dir.x + dir.y 不小于0.5就播放移动时的音效,代码在执行的过程中就不会重复执行这段代码,而且这样还能再多写一段为待机时的音效播放,实现音效的切换。
//切换音效
if(Mathf.Abs(dir.x) +Mathf.Abs(dir.y) >0.5f&&isOnIdle)
{
isOnIdle=false;
varaudioClip=Resources.Load<AudioClip>(GameConst.DrivingAudio);
tankAudio.Stop();
tankAudio.clip=audioClip;
if(!tankAudio.isPlaying)
{
tankAudio.Play();
}
}
elseif(Mathf.Abs(dir.x) +Mathf.Abs(dir.y) <0.5f&&!isOnIdle)
{
Debug.Log("111");
isOnIdle=true;
varaudioClip=Resources.Load<AudioClip>(GameConst.IdleAudio);
tankAudio.Stop();
tankAudio.clip=audioClip;
if (!tankAudio.isPlaying)
{
tankAudio.Play();
}
}
二、创建工具类内部定义静态方法供外界访问
usingConstant;
usingUnityEngine;
namespaceUtil
{
/// <summary>
/// 工具类
/// 描述:创建地图
/// </summary>
publicclassMapFactory : MonoBehaviour
{
/// <summary>
/// 创建地图对象
/// </summary>
/// <param name="goName">对象名字</param>
/// <param name="vector3">位置</param>
/// <param name="parent">父级</param>
publicstaticvoidCreateMapItem(stringgoName,Vector3vector3,Transformparent)
{
vargo=Resources.Load<GameObject>(goName);
Instantiate(go, vector3, Quaternion.identity, parent);
//存放入游戏上下文中的游戏对象地图字典
GameContext.GameObjectMap.Add($"{vector3.x}-{vector3.y}", go);
}
/// <summary>
/// 判断该位置是否有物体
/// 从字典中尝试获取物体,并且返回物体对象,判断是否为空,为空返回true
/// </summary>
/// <param name="vector3"></param>
/// <returns></returns>
publicstaticboolIsEmpty(Vector3vector3)
{
GameContext.GameObjectMap.TryGetValue($"{vector3.x}-{vector3.y}", outvargo);
returngo==null;
}
}
}
此类位于Util命名空间中,属于工具类型的类,类里的方法都定义为了静态方法,可以直接通过类名调用,不需要实例化该类,方便使用。
设计之妙:类中的方法都是静态方法,可以直接通过类名调用,不需要实例化该类,方便使用。两个方法都封装了一定的逻辑,这样在使用的时候,可以直接调用这些方法,不需要了解实现细节,提高了代码的可维护性。
三、在Contant命名空间下的静态类GameContext
usingSystem.Collections.Generic;
usingUnityEngine;
namespaceConstant
{
/// <summary>
/// 描述:该类为定义的游戏上下文
/// </summary>
publicstaticclassGameContext
{
//地图
publicstaticreadonlyDictionary<string, GameObject>GameObjectMap=newDictionary<string, GameObject>();
}
}
该静态类用于管理游戏中一些全局访问的变量和对象,例如游戏是否结束、分数、角色的生命值等等。
设计之妙:使用静态类可以很方便的使用和管理这些全局变量,它可以在游戏的如何地方访问这些全局变量,而不必担心变量的作用域问题,一定程度的避免在不同的对象之间传递参数的麻烦。
四、项目的功能进行模块化
项目中的许多功能都进行了模块化,如物体被摧毁需要播放音效(Valnerable)、子弹(Bullet)、主菜单管理器(MainSceneManager)、特效的播放等等,它都创建了一个类来对他进行管理。
usingConstant;
usingUnityEngine;
namespaceEntity
{
/// <summary>
/// 描述:该类为子弹类,实现子弹飞出,判断击中的物体的逻辑
/// </summary>
publicclassBullet : MonoBehaviour
{
privateconstfloatMoveSpeed=10f;
publicboolisPlayerBullet;
privatestaticintBulletLevel=>0;
privatevoidUpdate()
{
//子弹朝发射方向移动
transform.Translate(transform.up* (MoveSpeed*Time.deltaTime), Space.World);
}
privatevoidOnTriggerEnter2D(Collider2Dcollision)
{
HitTarget(collision);
}
//对击中的目标进行检测,并执行对应逻辑
privatevoidHitTarget(Collider2Dcoll)
{
switch(coll.tag)
{
caseGameConst.WallTag:
if (isPlayerBullet)
coll.SendMessage("PlayerAudio");
Destroy(gameObject);
Destroy(coll.gameObject);
break;
caseGameConst.BarrierTag:
//玩家子弹打到砖块播放音效
if (BulletLevel>1)
Destroy(coll.gameObject);
if (isPlayerBullet)
coll.SendMessage("PlayerAudio");
Destroy(gameObject);
break;
caseGameConst.TankTag:
if (!isPlayerBullet)
{
coll.SendMessage("Die");
Destroy(gameObject);
}
break;
caseGameConst.EnemyTag:
if(isPlayerBullet)
{
coll.SendMessage("Die");
Destroy(gameObject);
}
break;
caseGameConst.HomeTag:
//敌人子弹打到旗帜就游戏结束
Destroy(gameObject);
Destroy(coll.gameObject);
varhome=Resources.Load<GameObject>(GameConst.DieHomePrefab);
varposition=coll.transform.position;
Instantiate(home, position, Quaternion.identity);
varexplode=Resources.Load<GameObject>(GameConst.ExplodePrefab);
Instantiate(explode, position, Quaternion.identity);
GameContext.IsGameOver=true;
break;
}
}
}
}
子弹类:只负责一个特定的功能(飞行、飞行中碰撞到了谁),它来判断自己击中了谁,并调用它击中的目标在被击中时要执行的方法,并且该代码不仅能给玩家使用也能给敌人使用,可重用。
usingConstant;
usingUtil;
usingUnityEngine;
namespaceManager
{
///描述:该类为游戏场景管理器,用于创建随机场景,游戏对象
publicclassGameSceneManager:MonoBehaviour
{
//敌人的Prefab列表
privateObject[] enemyList;
privatevoidAwake()
{
//从Resources文件夹中加载Enmey的预制件
enemyList=Resources.LoadAll(GameConst.EnemyPrefab, typeof(GameObject));
}
privatevoidStart()
{
InitMap();//初始化地图
}
/// <summary>
/// 1,创建出生地
/// 2,创建随机地图
///
/// </summary>
privatevoidInitMap()
{
CreateHome();
CreatePlayer1();
if (!GameContext.IsSingle)
CreatePlayer2();
InvokeRepeating("CreateEnemy", 2f, 5f);
CreateRandomMap();
}
//创建玩家1
privatevoidCreatePlayer1()
{
MapFactory.CreateMapItem(GameConst.Player1Prefab, GameConst.Player1BornVector3, transform);
}
//创建玩家2
privatevoidCreatePlayer2()
{
MapFactory.CreateMapItem(GameConst.Player2Prefab, GameConst.Player2BornVector3, transform);
}
//创建敌人
privatevoidCreateEnemy()
{
if(GameContext.CurrentEnemyCount<GameConst.MAXEnemyCount)
{
intindex=Random.Range(0, enemyList.Length);
Vector3pos=GameConst.EnemyBornPosList[Random.Range(0, GameConst.EnemyBornPosList.Length)];
Instantiate(enemyList[index], pos, Quaternion.identity);
GameContext.CurrentEnemyCount++;
}
}
//创建出生地
privatevoidCreateHome()
{
MapFactory.CreateMapItem(GameConst.HomtPrefab, GameConst.HomeVector3, transform);
MapFactory.CreateMapItem(GameConst.WallPrefab, GameConst.HomeVector3-newVector3(-1,0,0), transform);
MapFactory.CreateMapItem(GameConst.WallPrefab, GameConst.HomeVector3-newVector3(1,0,0), transform);
MapFactory.CreateMapItem(GameConst.WallPrefab, GameConst.HomeVector3+newVector3(0,1,0), transform);
MapFactory.CreateMapItem(GameConst.WallPrefab, GameConst.HomeVector3+newVector3(1,1,0), transform);
MapFactory.CreateMapItem(GameConst.WallPrefab, GameConst.HomeVector3+newVector3(-1,1,0), transform);
}
/// <summary>
/// 创建随机随机地图
/// </summary>
privatevoidCreateRandomMap()
{
intgrassCount=Random.Range(15, 20);
intwallCount=Random.Range(40, 60);
intbarrierCount=Random.Range(15, 30);
intriverCount=Random.Range(15, 20);
for(inti=0;i<barrierCount;++i)
{
Vector3pos=CreateRandomPosition();
MapFactory.CreateMapItem(GameConst.BarrierPrefab, pos, transform);
}
for(inti=0; i<grassCount; ++i)
{
Vector3pos=CreateRandomPosition();
MapFactory.CreateMapItem(GameConst.GrassPrefab, pos, transform);
}
for(inti=0; i<riverCount; ++i)
{
Vector3pos=CreateRandomPosition();
MapFactory.CreateMapItem(GameConst.RiverPrefab, pos, transform);
}
for(inti=0; i<wallCount; ++i)
{
Vector3pos=CreateRandomPosition();
MapFactory.CreateMapItem(GameConst.WallPrefab, pos, transform);
}
}
/// <summary>
/// 随机空位置
/// </summary>
/// <returns></returns>
privateVector3CreateRandomPosition()
{
Vector3pos=newVector3(Random.Range(-10, 10), Random.Range(-8, 8), 0);
returnMapFactory.IsEmpty(pos) ?pos : CreateRandomPosition();
}
}
}
游戏场景管理类:它只负责场景里的对象,创建游戏场景,生成游戏玩家的对象还有敌人。体现了模块化设计的单一职责原则,只负责一个特定的功能。
设计之妙:将游戏中的功能进行模块化,它可以减少代码的复杂性和提高代码的可读性,而将代码分解为更小,更可重用的部分之后,那么代码的可重用性也增加了,在游戏的维护过程中,模块的划分也让增加了代码的可维护性了。
而在我们自己的程序中如何进行模块化?
- 当我们的项目在一个类里的写的内容巨大时,这时我们可以考虑将其中的内容模块化
- 将一串代码或一个组件进行模块化时,我们需要认真的思考他们的职责和作用
- 确定哪些组件比较常用,可以被重复使用
- 当有多个模块化的组件时,我们可以创建一个代码库来存储这些组件
- 给每个组件分配适当的接口和依赖项
- 每个模块应该是低耦合的,所以我们需要确定模块之间的依赖关系
- 测试和调试每个模块,确保他们都能正常工作
模块化对我们的代码有很大的好处。
- 更好的代码重用:模块化允许我们在不同的项目中重复使用已经编写好的代码,从而减少代码量和提高生产效率
- 更好的维护性:将代码分解为小的、独立的组件,有助于在进行维护和修复时更容易找到问题所在
- 更好的可读性:由于代码被分解成了更小的组件,所以更容易理解和阅读代码
- 更好的协作:在团队协作时,模块化使得多个开发人员可以并行的开发不同的组件,提高团队协作效率
那么,在游戏开发中,我们还可以对许多功能进行更细的模块化,如射击、移动、动画等等,可以创建射击管理器、移动管理器、动画管理器等类,将相关的功能逻辑封装在类中,方便管理和调用。
将游戏功能进行模块化,需要注意以下几点:
- 每个模块应该尽可能地高内聚,即每个模块内部彼此关联、相互依赖、同时尽量减少模块之间的耦合度,即不同模块之间尽量避免相互依赖和互相干扰。
- 每个模块应该具有单一的职责,即一个模块只负责一个特定的功能,不要将多功能耦合在一个模块内。
- 对于模块之间的通信,应该设计合适的接口,以便在不同模块之间进行交互和通信。
- 模块化设计应该考虑到模块的可重用性,即每个模块应该能够在不同场景和不同项目中重复使用。
- 模块化设计应该具有可扩展性,即每个模块应该能够在不同场景和不同项目中进行扩展和修改,以满足不同需求。
五、项目中的SendMessage函数
刚开始学习项目时发现了SendMessage函数,它可以直接通过函数名调用对象下的脚本里的函数,感觉这也太方便了,然后通过查找资料了解了这个函数。该函数是比较早期的消息转递机制,可以通过该函数来调用游戏对象上的脚本的函数,但是它的执行效率较低,因为需要使用反射机制来查找函数。
那么高效的消息传递机制有哪些?
- Delegate 和 Event :使用委托和事件机制来实现消息传递。
- UnityEvent:Unity自带的事件系统,能够在编辑器中直接拖拽赋值。
六、Resources
在项目的学习中,Resources遍布了整个项目,整个项目是使用Resources.Load函数从Resources文件夹中加载资源的,并实例化对象。
虽然它可以方便统一管理资源,方便查找和调用,但是Resources.Load方法会从硬盘中读取数据,因此使用过多回导致性能问题,因此应该尽可能地减少使用。
除了加载资源外,Resources还可以用于动态加载预制件和场景,动态地加载和卸载代码和预制件(使用ResourcesUnloadUnusedAssets()来卸载未使用的资源)。
七、使用namespace来对代码进行逻辑上的分组
使用namespace可以进行分组,将不同的代码按它的逻辑来分组,如一个代码是游戏对象上的可以分为Entity(或者游戏元素)、各个管理器(如UIManager分类为Manager)。这样的做法有需要好处:
- 避免命名冲突:在一个大型项目中,可能会出现多个类、函数、变量等命名相同的情况,这时就可以使用命名空间将它们分组,避免命名冲突。
- 代码结构清晰:使用命名空间可以将相关的类、函数、变量等放在同一个命名空间中,便于代码的组织和管理。
- 命名空间可以嵌套:命名空间可以嵌套,这样可以进一步将代码进行分组和归类。
- 更好的可读性:使用命名空间可以使代码更具可读性,更容易理解和维护。
- 提供了一种逻辑上的分组方式:命名空间是一种逻辑上的分组方式,不会对程序的性能产生任何影响。
总结
通过这个项目我学习到了:
- 如何用静态类对游戏中的一些常量、全局变量进行管理
- 将常用的功能封装成工具类在内部定义静态方法供外部进行调用
- 将各个功能进行模块化,每个模块都应该有自己的职责,且模块之间尽可能减少依赖
- 对Send Message的认识
- 对Resources的认识
- 了解了namespace在游戏开发中的好处
ps:这是我的学习的第一个开源游戏项目的笔记,也是第一篇博客。如果你看完了,希望可以在评论区提点建议给我,这对我的学习非常有帮助。
😄