更新日期:2024年6月25日。
项目源码:本章发布
索引
- 简介
- 关卡编辑器窗口类(LevelEditor)
- 一、定义关卡编辑器窗口类
- 二、两种编辑模式
- 三、地块编辑模式
- 1.关卡模板
- 2.打开编辑窗口
- 3.编辑器基本属性
- 4.地块模板
- 5.重新生成地图
- 6.地图刷子
- 7.刷地块
- 源码链接
简介
关卡编辑器
将是我们配置关卡地形,配置角色及要诀的重要工具,且关卡编辑器
应当能够从0到1完成一个关卡的创建。
关卡编辑器窗口类(LevelEditor)
一、定义关卡编辑器窗口类
首先,我们定义关卡编辑器窗口类LevelEditor
:
/// <summary>
/// 关卡编辑器
/// </summary>
public class LevelEditor : HTFEditorWindow
{
}
LevelEditor
继承至HTFEditorWindow
,使其具备一些预制的功能。
二、两种编辑模式
首先,我决定将关卡编辑器
分为2个模块(2种编辑模式)来完成:
- 1.地块编辑:针对关卡地形、地块的编辑功能;
- 2.角色编辑:针对关卡中存在的角色的编辑功能;
也即是说,关卡编辑器
可以算作半个角色编辑器
。
/// <summary>
/// 编辑模式
/// </summary>
public enum EditMode
{
/// <summary>
/// 地块编辑模式
/// </summary>
Map,
/// <summary>
/// 角色编辑模式
/// </summary>
Role
}
本章我们先着手完成地块编辑
模式。
三、地块编辑模式
1.关卡模板
首先,创建一个关卡模板(并创建为预制体),用我们在第二章建立的关卡基础层级物体:
然后,有必要自动化手动将关卡模板拖到场景中创建一个新的关卡
这一过程,我们新增CreateNewLevel
方法,并将其所处的类EditorToolKit
放到Editor文件夹中,此为我们后续的编辑器工具箱类:
internal static class EditorToolKit
{
[MenuItem("HTFramework/★ GameComponent/RPG2D/Create New Level", false, 320)]
private static void CreateNewLevel()
{
Object asset = AssetDatabase.LoadAssetAtPath<Object>("Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/LevelTmp.prefab");
if (asset)
{
GameObject level = Main.Clone(asset) as GameObject;
level.name = "Level";
level.transform.localPosition = Vector3.zero;
level.transform.localRotation = Quaternion.identity;
level.transform.localScale = Vector3.one;
Selection.activeGameObject = level;
EditorSceneManager.MarkSceneDirty(level.scene);
}
else
{
Log.Error("新建Level失败,丢失预制体:Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/LevelTmp.prefab");
}
}
}
现在,我们就可以通过点击菜单一键创建新的关卡了:
2.打开编辑窗口
创建新的关卡后,我们想将关卡编辑器
窗口的打开
与关卡预制体
建立关联,那么,在Level
中添加代码:
public class Level : SingletonBehaviourBase<Level>
{
#if UNITY_EDITOR
[Button("打开关卡编辑器")]
protected void OpenLevelEditor()
{
//确保当前关卡处于【预制体编辑模式】,才能打开关卡编辑器
//这样做是为了方便我们实时保存关卡所做的修改,并排除其他可能影响我们沉迷于关卡编辑的东西
//获取当前关卡对象的资源路径(如果是Scene中的物体则为null,Project中的物体则不为null)
string path = AssetDatabase.GetAssetPath(gameObject);
//获取当前编辑的预制体目标【预制体编辑模式】
PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
//确保【预制体编辑模式】的预制体目标为当前关卡对象,string.IsNullOrEmpty(path) 确保不是选中的 Project 中的预制体
if (string.IsNullOrEmpty(path) && prefabStage != null && prefabStage.prefabContentsRoot == gameObject)
{
//通过反射打开关卡编辑器窗口(因为其位于编辑器程序集,运行时程序集不可直接访问)
Type type = Type.GetType("HT.Framework.GC.RPG2D.LevelEditor,HTFramework.GC.Editor");
MethodInfo method = type.GetMethod("OpenWindow", BindingFlags.Static | BindingFlags.Public);
method.Invoke(null, new object[] { this });
}
else
{
Log.Error("请先将关卡设置为预制体,再打开预制体【Open Prefab】,再打开关卡编辑器编辑!");
}
}
#endif
}
Button
特性会在Level
类的检视器面板绘制一个按钮,点击按钮则调用此方法:
LevelEditor
类的打开方法:
public class LevelEditor : HTFEditorWindow
{
/// <summary>
/// 打开关卡编辑器
/// </summary>
public static void OpenWindow(Level level)
{
if (SceneView.lastActiveSceneView != null)
{
//强行将Scene视图修正为2D模式
SceneView.lastActiveSceneView.in2DMode = true;
}
LevelEditor levelEditor = GetWindow<LevelEditor>();
levelEditor._level = level;
levelEditor._blocks = level.Map.GetComponentsInChildren<Block>(true);
levelEditor._roles.AddRange(level.RolesRoot.GetComponentsInChildren<Role>(true));
levelEditor._editMode = EditMode.Map;
levelEditor.titleContent.text = "关卡编辑器";
levelEditor.minSize = new Vector2(300, 500);
levelEditor.maxSize = new Vector2(300, 1200);
levelEditor.Show();
}
}
很显然,我们刚才新创建的关卡还并未设为预制体,所以我们此时直接在上面点击打开关卡编辑器
是无效的:
3.编辑器基本属性
然后,我们回到LevelEditor
类,定义一些基本属性:
/// <summary>
/// 当前编辑的关卡
/// </summary>
private Level _level;
/// <summary>
/// 关卡的所有地块
/// </summary>
private Block[] _blocks;
/// <summary>
/// 关卡的所有角色
/// </summary>
private List<Role> _roles = new List<Role>();
/// <summary>
/// 地块模板
/// </summary>
private GameObject _blockTmp;
/// <summary>
/// 角色模板
/// </summary>
private GameObject _roleTmp;
/// <summary>
/// 当前的编辑模式
/// </summary>
private EditMode _editMode = EditMode.Map;
接着在OnBodyGUI
中绘制这些属性:
protected override void OnBodyGUI()
{
base.OnBodyGUI();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("关卡:", GUILayout.Width(80));
EditorGUILayout.ObjectField(_level, typeof(GameObject), false);
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("地块数量:", GUILayout.Width(80));
EditorGUILayout.LabelField(_blocks.Length.ToString());
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("角色数量:", GUILayout.Width(80));
EditorGUILayout.LabelField(_roles.Count.ToString());
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("地块模板:", GUILayout.Width(80));
_blockTmp = EditorGUILayout.ObjectField(_blockTmp, typeof(GameObject), false) as GameObject;
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("角色模板:", GUILayout.Width(80));
_roleTmp = EditorGUILayout.ObjectField(_roleTmp, typeof(GameObject), false) as GameObject;
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("编辑模式:", GUILayout.Width(80));
GUI.color = _editMode == EditMode.Map ? Color.white : Color.gray;
if (GUILayout.Toggle(_editMode == EditMode.Map, "地块编辑", "ButtonLeft"))
{
_editMode = EditMode.Map;
}
GUI.color = _editMode == EditMode.Role ? Color.white : Color.gray;
if (GUILayout.Toggle(_editMode == EditMode.Role, "角色编辑", "ButtonRight"))
{
_editMode = EditMode.Role;
}
GUI.color = Color.white;
EditorGUILayout.EndHorizontal();
if (_editMode == EditMode.Map)
{
EditMapGUI();
}
else if (_editMode == EditMode.Role)
{
EditRoleGUI();
}
//这一步尤其重要,当GUI界面发生变化时,将关卡物体设置为【已改变】,否则保存关卡可能会出现异常
if (GUI.changed)
{
HasChanged(_level.gameObject);
}
}
//地块编辑模式GUI
private void EditMapGUI()
{
}
//角色编辑模式GUI
private void EditRoleGUI()
{
}
OnBodyGUI
用于绘制窗口主体部分,与之对应的OnTitleGUI
则用于绘制窗口标题栏。
此时打开关卡编辑器
窗口便已经有一些内容了:
4.地块模板
按如下方式建立地块模板(我们的本意是所有地块均通过模板创建,当然之后的角色也一样):
然后在关卡编辑器打开时(OpenWindow
),自动搜索地块模板:
levelEditor._blockTmp = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/BlockTmp.prefab");
5.重新生成地图
也即是按指定的长和宽
重新生成地图(自动从地块模板创建长*宽
数量的全部地块):
/// <summary>
/// 重新生成地图
/// </summary>
private void GenerateMap()
{
EditorUtility.DisplayProgressBar("重新生成地图", "正在重新生成地图......", 0);
Block[,] blocks = new Block[_level.MapSize.x, _level.MapSize.y];
//整理旧的地块,超出新地图尺寸的删除,不超出新地图尺寸的保留
Block[] oldBlocks = _level.Map.GetComponentsInChildren<Block>(true);
for (int i = 0; i < oldBlocks.Length; i++)
{
Block block = oldBlocks[i];
if (block.Pos.x < _level.MapSize.x && block.Pos.y < _level.MapSize.y)
{
blocks[block.Pos.x, block.Pos.y] = block;
}
else
{
Main.KillImmediate(block.gameObject);
}
}
//生成新地块,旧地图有的不生成,没有的才生成
for (int i = 0; i < _level.MapSize.x; i++)
{
for (int j = 0; j < _level.MapSize.y; j++)
{
if (blocks[i, j] == null)
{
GameObject obj = PrefabUtility.InstantiatePrefab(_blockTmp) as GameObject;
obj.name = $"Block({i},{j})";
obj.transform.SetParent(_level.Map.transform);
obj.transform.localPosition = new Vector3(1.3f * i, 1.3f * j, 0);
obj.transform.localRotation = Quaternion.identity;
obj.transform.localScale = Vector3.one;
obj.SetActive(true);
blocks[i, j] = obj.GetComponent<Block>();
blocks[i, j].GetComponent<SpriteRenderer>().sprite = _level.MapBrushGround;
blocks[i, j].Pos = new Vector2Int(i, j);
EditorUtility.SetDirty(blocks[i, j]);
EditorUtility.DisplayProgressBar("重新生成地图", "正在重新生成地图......", (float)i / _level.MapSize.x);
}
}
}
//重新搜索所有地块
_blocks = _level.Map.GetComponentsInChildren<Block>(true);
EditorUtility.ClearProgressBar();
}
这一方法将由窗口页面的按钮调用,不过由于窗口中的UI控件代码
简单易懂,所以后续我便不再讲解UI界面的布局、实现等(除非一些刁钻的布局需求),因为只要看一眼源码便懂了。
那么此处,在界面上输入长和宽,点击确认按钮
就可调用上面的方法,重新生成地图:
6.地图刷子
有了上一步生成的空白地图,接下来需要使用刷子
功能,使得我们可以快速将地图上指定的地块
刷为指定的类型
。
定义如下的刷子控制变量:
/// <summary>
/// 是否激活地块刷子
/// </summary>
private bool _isActiveMapBrush = false;
/// <summary>
/// 地块刷子类型名称
/// </summary>
private string[] _mapBrushTypeName = new string[] { "地面", "山体", "森林", "湖泊", "雪地", "障碍" };
/// <summary>
/// 地块刷子类型
/// </summary>
private BlockType _mapBrushType = BlockType.Ground;
6种地块类型,每种类型均有自己的显示图像
,且不同的关卡可能不一样,甚至同一个关卡也可能不一样。
那么,我们需要存储这6种地块类型的图像,最好将其与Level
类关联,使得我们下一次编辑同一关卡时有缓存功能:
public class Level : SingletonBehaviourBase<Level>
{
#if UNITY_EDITOR
/// <summary>
/// 地块刷子贴图(地面)
/// </summary>
[HideInInspector] public Sprite MapBrushGround;
/// <summary>
/// 地块刷子贴图(山体)
/// </summary>
[HideInInspector] public Sprite MapBrushMoutain;
/// <summary>
/// 地块刷子贴图(森林)
/// </summary>
[HideInInspector] public Sprite MapBrushForest;
/// <summary>
/// 地块刷子贴图(湖泊)
/// </summary>
[HideInInspector] public Sprite MapBrushWater;
/// <summary>
/// 地块刷子贴图(雪地)
/// </summary>
[HideInInspector] public Sprite MapBrushSnow;
/// <summary>
/// 地块刷子贴图(障碍)
/// </summary>
[HideInInspector] public Sprite MapBrushObstacle;
#endif
}
紧接着就是在编辑器窗口关联并显示这些图像(省略UI控件代码):
7.刷地块
如上所示,按下键盘左上角数字1(这个位置很方便左手)
,便使用当前刷子类型
,当前刷子贴图
,在鼠标当前位置刷地块
。
此功能,需要在OnSceneGui(Unity编辑器回调方法)
中干一些事:
private void OnSceneGui(SceneView sceneView)
{
if (Event.current == null)
return;
if (_editMode == EditMode.Map && _isActiveMapBrush)
{
//是否按下键盘1键
if (Event.current.isKey && Event.current.keyCode == KeyCode.Alpha1 && Event.current.type == EventType.KeyDown)
{
//将Scene视图坐标转换为世界坐标
Vector2 pos = ScreenToWorldPointInScene(sceneView.camera, Event.current.mousePosition);
//获取坐标位置的地块
Block block = GetBlockByPoint(pos);
if (block)
{
//刷地块
block.Type = _mapBrushType;
switch (_mapBrushType)
{
case BlockType.Ground:
block.Target.sprite = _level.MapBrushGround;
break;
case BlockType.Moutain:
block.Target.sprite = _level.MapBrushMoutain;
break;
case BlockType.Forest:
block.Target.sprite = _level.MapBrushForest;
break;
case BlockType.Water:
block.Target.sprite = _level.MapBrushWater;
break;
case BlockType.Snow:
block.Target.sprite = _level.MapBrushSnow;
break;
case BlockType.Obstacle:
block.Target.sprite = _level.MapBrushObstacle;
break;
}
}
}
EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.SlideArrow);
}
}
/// <summary>
/// 获取一个点所在的地块
/// </summary>
/// <param name="hitPos">点</param>
private Block GetBlockByPoint(Vector2 hitPos)
{
Rect rect = Rect.zero;
for (int i = 0; i < _blocks.Length; i++)
{
Block block = _blocks[i];
//获取地块区域
rect.Set(block.transform.localPosition.x - 0.65f, block.transform.localPosition.y - 0.65f, 1.3f, 1.3f);
//如果地块区域中包含此点,则此点正在此地块上
if (rect.Contains(hitPos))
{
return block;
}
}
return null;
}
当然,同一种地形,比如地面
,你使用图像1
刷了若干后,很显然是可以使用图像2
继续刷的,就像前面说的,同一个关卡中的同一种类型的地块显示图像是可以不一样的,也即是说地块的类型与显示图像是不相关的,这样也就给了关卡创建者更大的施展空间。
此时,地形编辑的功能便基本完备了。