该工具已上传个人仓库,预计未来会添加更多辅助游戏开发的工具,敬请关注IceinNorth/Ice-Game-Development-Toolkit: 自制的一些方便游戏开发的工具 (github.com)
目录
前言
一直想做一个非线性的2D探索解谜游戏,不过unity自带的Scene Manager其实更适合拿来做一些线性关卡结构或者选关结构的游戏,所以趁着还没开学自己尝试写了一个工具来辅助管理关卡。
一开始是打算在Scene Manager的基础上来制作这个工具,不过scene在异步加载上存在一些限制导致不能实现我原有的想法,所以后来也是换成prefab来制作这个工具。
功能演示
该工具可以根据关卡的命名自动进行四个方向上关卡的切换
无需手动设置关卡的出入口,使用时只需要在关卡预制体的名字中添加坐标形式的数字即可
命名时只需要包含两组数字即可,对格式没有太多要求(两组数字中间必须用其他符号隔开,建议数字在前方便Project内关卡的排序)
效果如下(注意观察Hierarchy面板中的关卡的状态)
注:演示中的关卡较为简单可以达到无缝加载的效果,如果关卡体量较为大的话需要自行在代码中添加协程进行关卡的异步加载(以后说不定会实现异步加载的功能)
源码
部分内容需要根据项目自行更改,具体详见代码内注释
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;//正则表达式命名空间
using UnityEngine;
//------------------------------------------------------------
// 作者名:一冰在北
// GitHub项目地址:https://github.com/IceinNorth/Ice-Game-Development-Toolkit
// 邮箱:wu2687180662@icloud.com
// MIT License
//------------------------------------------------------------
public class LevelManager : MonoBehaviour
{
public Transform PlayerTr;
public Camera Camera;
public GameObject ActiveLevel; //启用的关卡
private Action ActiveAction; //关卡切换事件
private List<GameObject> loadedLevelList; //已加载关卡列表
private GameObject[] levels; //文件中的所有关卡
private Vector2Int oldIndex; //上一个关卡索引:用于防止切换后上一个关卡被卸载
public Vector2Int nowIndex; //以坐标为形式的当前关卡索引
private bool IsNotVisableInCamera; //玩家是否离开视线范围
void OnEnable()
{
//事件订阅
ActiveAction += UnloadLevels;
ActiveAction += LoadLevels;
ActiveAction += SetLevelActive;
}
void Start()
{
DontDestroyOnLoad(this); //非必须
//实例化
//获取主摄像机,也可以通过Inspector面板拖拽获取
Camera =Camera == null? Camera.main:Camera;
//这里使用名为“Player”的标签来获取玩家的transform组件,也可以通过Inspector面板拖拽获取
PlayerTr = PlayerTr == null? GameObject.FindWithTag("Player").transform:PlayerTr;
//这里会获取场景中标签为“Level”的物体,也可以通过Inspector面板拖拽获取
ActiveLevel = ActiveLevel == null? GameObject.FindWithTag("Levels"):ActiveLevel;
levels = Resources.LoadAll<GameObject>("Levels"); //需要在Assets中创建Resource文件夹并在其中创建关卡文件夹,关卡文件夹名字自定,这里使用“Levels”
levels = Resources.LoadAll<GameObject>("Levels"); //需要在Assets中创建Resource文件夹并在其中创建关卡文件夹,关卡文件夹名字自定,这里使用”Levels“
loadedLevelList = new List<GameObject>();
//初始化
loadedLevelList.Add(ActiveLevel); //列表添加当前关卡
nowIndex = GetIndex(ActiveLevel); //获取当前关卡索引
ActiveAction(); //初始化
}
// Update is called once per frame
void Update()
{
nowIndex = SwitchPlayer(PlayerTr,nowIndex); //更新当前索引
if (IsNotVisableInCamera)
{
//玩家离开视线后进行切换事件
ActiveAction();
}
}
private Vector2Int SwitchPlayer(Transform transform,Vector2Int index)
{
//当玩家离开视线后返回新索引
Vector3 pos = transform.position; //获取玩家坐标
Vector3 viewPos = Camera.WorldToViewportPoint(pos); //玩家坐标从世界空间转换为视图空间
//视线内的坐标的xy值会被转换为0-1之间的数值,超出视线则xy值不在此范围,借此来判断玩家离开视线的方向
if (viewPos.x < 0)
{
//当玩家向左离开时
index.x -= 1; //索引坐标更新
pos.x =-pos.x-0.2f; //玩家被移动到视线的另一端,此处的-0.2f是为了防止玩家在移到视线另一端后仍在视线外而造成关卡再次切换,此值偏小会导致关卡反复切换
IsNotVisableInCamera = true; //更新布尔值来在update中调用切换事件
}
//以下同理
else if (viewPos.x > 1)
{
index.x += 1;
pos.x = -pos.x+0.2f;
IsNotVisableInCamera = true;
}
else if (viewPos.y < 0)
{
index.y -= 1;
pos.y =-pos.y-0.1f;
IsNotVisableInCamera = true;
}
else if (viewPos.y > 1)
{
index.y += 1;
pos.y = -pos.y+1.5f; //玩家向上移动时的偏移量需要比其他方向上更大才能防止关卡反复切换,原因未知
IsNotVisableInCamera = true;
}
else
{
//玩家未离开视线
IsNotVisableInCamera = false;
}
transform.position = pos; //坐标在函数内部更新后再赋给position
return index; //返回更新后的索引
}
private Vector2Int GetIndex(GameObject Level)
{
//根据关卡预制体返回索引
Vector2Int index = Vector2Int.zero; //声明一个临时索引
bool xChanged = false;//用于判断x是否被改变
Regex reg = new Regex("[0-9]+", RegexOptions.IgnoreCase | RegexOptions.Singleline); //声明一个正则表达式,该表达式只会提取文本中的数字
MatchCollection mc = reg.Matches(Level.name); //通过表达式来提取关卡名中的数字,如果关卡名为”2,3 示例关卡“,进行提取后就会产生包含2和3的集合
foreach (Match m in mc)
{
if (!xChanged)
{
index.x = int.Parse(m.Groups[0].Value);
xChanged = true;
}
else
{
index.y = int.Parse(m.Groups[0].Value);
}
}
return index; //返回得到的索引
}
private void LoadLevels()
{
//加载四个方向上的关卡
Load(nowIndex+Vector2Int.up);
Load(nowIndex+Vector2Int.down);
Load(nowIndex+Vector2Int.left);
Load(nowIndex+Vector2Int.right);
void Load(Vector2Int index)
{
bool levelInList = false; //该布尔值用于判断关卡是否已经加载
string name = index.x + "," + index.y; //索引转换为字符串
foreach (GameObject i in loadedLevelList)
{
//遍历已加载的关卡,判断将要加载关卡是否已经加载
if (i.name == name)
{
levelInList = true;
}
}
if (!levelInList)
{
foreach (GameObject i in levels)
{
//遍历所有关卡
if (index == GetIndex(i))
{
GameObject level = Instantiate(i); //将获取到的关卡载入场景中
level.name = name; //载入关卡后名字改为索引,方便调用
level.SetActive(false); //禁用关卡
loadedLevelList.Add(level); //该关卡添加进入已加载关卡列表
break;
}
}
}
}
}
private void UnloadLevels()
{
//卸载关卡
string name = nowIndex.x + "," + nowIndex.y; //索引转字符串
for (int i = loadedLevelList.Count-1; i >= 0; i--)
{
//这里需要遍历列表并按条件移除关卡,采用倒历的方法来遍历列表
GameObject level = loadedLevelList[i];
if (level != ActiveLevel && level.name != name)
{
//如果该关卡不是上一个关卡和当前关卡便移除
loadedLevelList.Remove(level);
Destroy(level);
}
}
}
private void SetLevelActive()
{
//启用关卡
string name = nowIndex.x + "," + nowIndex.y; // 索引转字符串
foreach (GameObject i in loadedLevelList)
{
//遍历已加载关卡列表
if (i.name == name)
{
//当前关卡启用
i.SetActive(true);
ActiveLevel = i;
}
else
{
//否则禁用
i.SetActive(false);
}
}
}
}
使用方法
1.创建一个空对象并将脚本添加其上
2.将关卡以预制体的形式保存到Assets/Resource/Levels目录下,并在命名时为预制体添加两组数字作为索引坐标
可选:为每个预制体添加名称为“Levels”的标签,这一步是为了方便工具在初始化时获取场景中已经存在的关卡
3.将玩家、主相机、第一个要加载的场景分别拖拽到脚本中的对应位置
如果没有拖拽的话工具会自行从场景中获取名为“Player”的对象、主相机和标签为“Levels”的对象
注:该工具通过判断玩家是否离开相机来切换关卡,使用时请保证相机视野与关卡边界贴合
4.运行并检查效果
制作思路(请结合上文源码食用)
这个工具主要分为三个模块
1.关卡文件处理模块:获取所有的关卡预制体并将名称转为二维索引的形式
这里使用了Unity中Resources相关的方法来把Levels文件添加到一个列表中
levels = Resources.LoadAll<GameObject>("Levels");
接着编写一个用于将名字转换为索引的的函数
//这里使用了正则表达式来提取字符串中的数字 using System.Text.RegularExpressions;//正则表达式命名空间 private Vector2Int GetIndex(GameObject Level) { //根据关卡预制体返回索引 Vector2Int index = Vector2Int.zero; //声明一个临时索引 bool xChanged = false;//用于判断x是否被改变 Regex reg = new Regex("[0-9]+", RegexOptions.IgnoreCase | RegexOptions.Singleline); //声明一个正则表达式,该表达式只会提取文本中的数字 MatchCollection mc = reg.Matches(Level.name); //通过表达式来提取关卡名中的数字,如果关卡名为”2,3 示例关卡“,进行提取后就会产生包含2和3的集合 foreach (Match m in mc) { if (!xChanged) { index.x = int.Parse(m.Groups[0].Value); xChanged = true; } else { index.y = int.Parse(m.Groups[0].Value); } } return index; //返回得到的索引 }
2.玩家位置判断模块:判断玩家是否离开了相机的视野以及离开的方向
将玩家坐标从世界空间转为视图空间,利用视图空间内的坐标来判断玩家的位置,同时根据离开方向和当前关卡索引来返回要切换关卡的索引
private Vector2Int SwitchPlayer(Transform transform,Vector2Int index) { //当玩家离开视线后返回新索引 Vector3 pos = transform.position; //获取玩家坐标 Vector3 viewPos = Camera.WorldToViewportPoint(pos); //玩家坐标从世界空间转换为视图空间 //视线内的坐标的xy值会被转换为0-1之间的数值,超出视线则xy值不在此范围,借此来判断玩家离开视线的方向 if (viewPos.x < 0) { //当玩家向左离开时 index.x -= 1; //索引坐标更新 pos.x =-pos.x-0.2f; //玩家被移动到视线的另一端,此处的-0.2f是为了防止玩家在移到视线另一端后仍在视线外而造成关卡再次切换,此值偏小会导致关卡反复切换 IsNotVisableInCamera = true; //更新布尔值来在update中调用切换事件 } //以下同理 else if (viewPos.x > 1) { index.x += 1; pos.x = -pos.x+0.2f; IsNotVisableInCamera = true; } else if (viewPos.y < 0) { index.y -= 1; pos.y =-pos.y-0.1f; IsNotVisableInCamera = true; } else if (viewPos.y > 1) { index.y += 1; pos.y = -pos.y+1.5f; //玩家向上移动时的偏移量需要比其他方向上更大才能防止关卡反复切换,原因未知 IsNotVisableInCamera = true; } else { //玩家未离开视线 IsNotVisableInCamera = false; } transform.position = pos; //坐标在函数内部更新后再赋给position return index; //返回更新后的索引 }
3.关卡管理模块: 控制关卡的卸载、加载、启用和禁用
这里采用卸载、加载、启用从左往右的优先级进行关卡的管理,来减少方法内部的消耗
三个方法分别如下
卸载
private void UnloadLevels() { //卸载关卡 string name = nowIndex.x + "," + nowIndex.y; //索引转字符串 for (int i = loadedLevelList.Count-1; i >= 0; i--) { //这里需要遍历列表并按条件移除关卡,采用倒历的方法来遍历列表 GameObject level = loadedLevelList[i]; if (level != ActiveLevel && level.name != name) { //如果该关卡不是上一个关卡和当前关卡便移除 loadedLevelList.Remove(level); Destroy(level); } } }
加载
private void LoadLevels() { //加载四个方向上的关卡 Load(nowIndex+Vector2Int.up); Load(nowIndex+Vector2Int.down); Load(nowIndex+Vector2Int.left); Load(nowIndex+Vector2Int.right); void Load(Vector2Int index) { bool levelInList = false; //该布尔值用于判断关卡是否已经加载 string name = index.x + "," + index.y; //索引转换为字符串 foreach (GameObject i in loadedLevelList) { //遍历已加载的关卡,判断将要加载关卡是否已经加载 if (i.name == name) { levelInList = true; } } if (!levelInList) { foreach (GameObject i in levels) { //遍历所有关卡 if (index == GetIndex(i)) { GameObject level = Instantiate(i); //将获取到的关卡载入场景中 level.name = name; //载入关卡后名字改为索引,方便调用 level.SetActive(false); //禁用关卡 loadedLevelList.Add(level); //该关卡添加进入已加载关卡列表 break; } } } } }
启用和禁用
private void SetLevelActive() { //启用关卡 string name = nowIndex.x + "," + nowIndex.y; // 索引转字符串 foreach (GameObject i in loadedLevelList) { //遍历已加载关卡列表 if (i.name == name) { //当前关卡启用 i.SetActive(true); ActiveLevel = i; } else { //否则禁用 i.SetActive(false); } } }
通过事件将三个方法合并
private Action ActiveAction; //关卡切换事件 void OnEnable() { //事件订阅 ActiveAction += UnloadLevels; ActiveAction += LoadLevels; ActiveAction += SetLevelActive; }
这里主要是写明了思路,想了解变量的引用和模块之间的调用还请结合上文源码分析
PS
这是我的第一篇博客,接触Unity和C#也不过两年,因为经验不足,所以在代码上可能还有些小瑕疵,如果有什么见解或问题的话欢迎在评论里提出,日后还会带来更多的工具和其他方面的分享