第三章 绘制地图
四 初步完善地图编辑器(Map Graph)
到目前为止我们可以开心的绘制我们的地图了,但有不少小问题。一直开心忘我的绘制地图,却不知道地图已经绘制了多大,还要去看到底大小有没有超标。像其它地图编辑器都会有相关参数显示。我们也开始创建有些有助于绘制的显示参数,让它更像一个标准的地图编辑器,创建我们的MapGraph.cs。
1 地图大小(Map Rect)
第一部要做的是确认地图的大小,我们要先确定我们地图绘制大小,这就可以知道我们绘制的东西是不是已经超越边界。比如一个10x10的地图:
你可以使用确定对角线Position的方式。
public Vector3Int m_LeftDownPosition = Vector3Int.zero; public Vector3Int m_RightUpPosition = new Vector3Int(9, 9, 0); public RectInt mapRect { get { return new RectInt( m_LeftDownPosition.x, m_LeftDownPosition.y, m_RightUpPosition.x - m_LeftDownPosition.x + 1, m_RightUpPosition.y - m_LeftDownPosition.y + 1); } }
也可以直接使用矩形,我们这里直接使用整形的矩形RectInt。
public RectInt m_MapRect = new RectInt(0, 0, 10, 10); public Vector3Int leftDownPosition { get { return new Vector3Int(m_MapRect.xMin, m_MapRect.yMin, 0); } } public Vector3Int rightUpPosition { get { return new Vector3Int(m_MapRect.xMax - 1, m_MapRect.yMax - 1, 0); } }
接下来我们添加一些常用的属性(Property),地图的宽(width)与高(height)。
public int width { get { return m_MapRect.width; } } public int height { get { return m_MapRect.height; } }
2 在Scene面板中绘制地图边框(Border)
在Unity中绘制辅助线的方式有多种,比如“Gizmos”和“Handles”。其中Gizmos只能在OnDrawGizmos()与OnDrawGizmosSelected()两个Unity的Callback中使用,而Handles范围就广一些,而且功能更强大。我们需要在Scene中绘制Cell的Position,所以使用两种混用的方法。你还需要知道,Handles是在UnityEditor的命名空间中,在MonoBehaviour中需要在#if UNITY_EDITOR与#endif之间使用。
- OnDrawGizmos():无论是否被选中,都在Scene面板中渲染;
- OnDrawGizmosSelected():只有选中状态下,才在Scene面板中渲染。
我们先在MapGraph.cs中创建这一领域。
#if UNITY_EDITOR using UnityEditor; #endif … #if UNITY_EDITOR private void OnDrawGizmos() { EditorDrawBorderGizmos(); } protected void EditorDrawBorderGizmos() { } #endif …
绘制边框,我们采用Gizmos的DrawWireCube(Vector3 center, Vector3 size)方法。从中我们看出,需要中心点与大小。而计算他们主要还是靠Grid组件获取Cell的世界坐标方法(world position),所以我们添加Grid组件。
private Grid m_Grid; public Grid grid { get { if (m_Grid == null) { m_Grid = GetComponent<Grid>(); } return m_Grid; } }
先来看一张坐标系的图。
图 3 - 17计算Border的Center
你要知道的是,Grid获取Cell的中心世界坐标,所以左下角要减去Cell Size的一半;同样的,右上角要加上Cell Size的一半;而且width与height是指Cell的数量,你还要乘以Cell Size才是他们的真正长与宽。
public Vector3 halfCellSize { get { return grid.cellSize / 2f; } }
完整的EditorDrawBorderGizmos():
protected void EditorDrawBorderGizmos() { Color old = Gizmos.color; GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = m_EditorBorderColor; // 获取边框左下角与右上角的世界坐标 Vector3 leftDown = grid.GetCellCenterWorld(leftDownPosition) - halfCellSize; Vector3 rightUp = grid.GetCellCenterWorld(rightUpPosition) + halfCellSize; // 绘制左下角Cell与右上角Cell的Position Handles.Label(leftDown, (new Vector2Int(leftDownPosition.x, leftDownPosition.y)).ToString(), textStyle); Handles.Label(rightUp, (new Vector2Int(rightUpPosition.x, rightUpPosition.y)).ToString(), textStyle); if (mapRect.width > 0 && mapRect.height > 0) { Gizmos.color = m_EditorBorderColor; // 边框的长与宽 Vector3 size = Vector3.Scale(new Vector3(width, height), grid.cellSize); // 边框的中心坐标 Vector3 center = leftDown + size / 2f; // 绘制边框 Gizmos.DrawWireCube(center, size); } Gizmos.color = old; }
其中有一些新出现的东西:
- Handles.Label:在Scene面板中绘制文字;
- m_EditorBorderColor:边框颜色。
这使得我们可以改变边框颜色,不至于让颜色被淹没。再来额外添加一些需要用到的变量,来控制是否需要绘制Gizmos,并修改OnDrawGizmos()。
#if UNITY_EDITOR [Header("Editor Gizmos")] public bool m_EditorDrawGizmos = true; public Color m_EditorBorderColor = Color.white; public Color m_EditorCellColor = Color.green; public Color m_EditorErrorColor = Color.red; private void OnDrawGizmos() { if (m_EditorDrawGizmos) { EditorDrawBorderGizmos(); } } … #endif
好了,来看看我们的效果。
图 3 - 18绘制Border效果图
需要注意的是(0, 1)不是世界坐标,而是右上角Cell的Position。
这样我们就绘制完成了边框,既能改变颜色,又能在Inspector面板控制是否显示它,绘制的时候也知道是不是越界了。但是,RectInt的宽与高是可以为负数的,这就不对了。接下来,我们来限定它并在Scene面板中绘制一些信息。
3 在Scene面板中绘制地图信息(Information)
这部分工作,我们在Editor中进行,所以在Editor文件夹下创建新文件MapGraphEditor.cs。
3.1 限定宽与高
这里我们限定宽与高都不能低于2。
public MapGraph map { get { return target as MapGraph; } } public override void OnInspectorGUI() { DrawDefaultInspector(); // 检测地图长宽是否正确,如果不正确就修正 if (map.mapRect.width < 2 || map.mapRect.height < 2) { RectInt fix = map.mapRect; fix.width = Mathf.Max(map.mapRect.width, 2); fix.height = Mathf.Max(map.mapRect.height, 2); map.mapRect = fix; } }
DrawDefaultInspector()是用来绘制本来的Inspector面板。我们暂时并不需要自定义Inspector面板,所以使用它。
3.2 绘制地图信息
在Scene面板中绘制GUI是需要在Unity的OnSceneGUI()方法中进行。而且在这之中,要在Handles.BeginGUI()和Handles.EndGUI()中间进行绘制。还要创建GUI块。
// Scene面板左上角显示信息 Handles.BeginGUI(); { Rect areaRect = new Rect(50, 50, 200, 200); GUILayout.BeginArea(areaRect); { // 你的GUILayout代码 } GUILayout.EndArea(); } Handles.EndGUI();
再来创建一个绘制两个横向Label方法供我们使用。
protected void DrawHorizontalLabel(string name, string value, GUIStyle style = null, int nameMaxWidth = 80, int valueMaxWdith = 120) { EditorGUILayout.BeginHorizontal(); if (style == null) { EditorGUILayout.LabelField(name, GUILayout.MaxWidth(nameMaxWidth)); EditorGUILayout.LabelField(value, GUILayout.MaxWidth(valueMaxWdith)); } else { EditorGUILayout.LabelField(name, style, GUILayout.MaxWidth(nameMaxWidth)); EditorGUILayout.LabelField(value, style, GUILayout.MaxWidth(valueMaxWdith)); } EditorGUILayout.EndHorizontal(); }
完成后的OnSceneGUI():
protected virtual void OnSceneGUI() { if (!map.m_EditorDrawGizmos) { return; } GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = map.m_EditorCellColor; // Scene面板左上角显示信息 Handles.BeginGUI(); { Rect areaRect = new Rect(50, 50, 200, 200); GUILayout.BeginArea(areaRect); { // 你的GUILayout代码 DrawHorizontalLabel("Object Name:", map.gameObject.name, textStyle); DrawHorizontalLabel("Map Name:", map.mapName, textStyle); DrawHorizontalLabel("Map Size:", map.width + "x" + map.height, textStyle); DrawHorizontalLabel("Cell Size:", map.grid.cellSize.x + "x" + map.grid.cellSize.y, textStyle); } GUILayout.EndArea(); } Handles.EndGUI(); }
完成后的效果图:
图 3 - 19绘制信息
我们的Scene面板看起来不错。
那么又有新问题来了,如果地图非常的大,比如128*128,那绘制中间的时候,也不知道我们绘制到第几个Cell了,好吧,我们继续改造它。
4 在Scene面板中绘制鼠标(Mouse)所在Cell
回到我们的MapGraph.cs这次使用OnDrawGizmosSelected()方法来绘制,只有选中地图时才显示,添加新方法。
private void OnDrawGizmosSelected() { if (m_EditorDrawGizmos) { EditorDrawCellGizmos(); } } protected void EditorDrawCellGizmos() { }
首先,我们要知道鼠标位置是指Scene面板中的,而不是Game面板中,而且游戏也没有在运行,所以不能用Input.mousePosition,而应该使用Event.current.mousePosition。
Event e = Event.current; Vector2 mousePosition = e.mousePosition;
其次,同样的理由,转换成世界坐标时,不能使用Camera.main(场景中也可能就没有Camera),而应该是Scene面板的Camera。
最后,Event所获取的mousePosition是从屏幕左上角(Left Up)开始的,而Camera是从屏幕左下角(Left Down)开始的。所以转换世界坐标时,不能直接使用。
// 获取当前操作Scene面板 SceneView sceneView = SceneView.currentDrawingSceneView; /// 获取鼠标世界坐标: /// Event是从左上角(Left Up)开始, /// 而Camera是从左下角(Left Down), /// 需要转换才能使用Camera的ScreenToWorldPoint方法。 Vector2 screenPosition = new Vector2(e.mousePosition.x, sceneView.camera.pixelHeight - e.mousePosition.y); Vector2 worldPosition = sceneView.camera.ScreenToWorldPoint(screenPosition); // 当前鼠标所在Cell的Position Vector3Int cellPostion = grid.WorldToCell(worldPosition); // 当前鼠标所在Cell的Center坐标 Vector3 cellCenter = grid.GetCellCenterWorld(cellPostion);
接下来绘制我们选中的Cell,同样使用了Gizmos.DrawWireCube:
GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = m_EditorCellColor; Gizmos.color = m_EditorCellColor; Handles.Label(cellCenter - halfCellSize, (new Vector2Int(cellPostion.x, cellPostion.y)).ToString(), textStyle); Gizmos.DrawWireCube(cellCenter, grid.cellSize);
图 3 - 20绘制Cell
绘制是绘制出来了,可以却又有新问题了,有些时候过好一阵子才会跟着鼠标渲染,这是由于这些方法都不是每帧运行的。接下来,我们来修正这一问题。
5 每帧刷新Scene面板(Update Scene)
回到MapGraphEditor.cs中,我们添加一个方法。
/// <summary> /// 立即刷新Scene面板,这保证了每帧都运行(包括Gizmos)。 /// 如果在OnSceneGUI或Gizmos里获取鼠标,需要每帧都运行。 /// </summary> protected void UpdateSceneGUI() { HandleUtility.Repaint(); }
用HandleUtility.Repaint()方法来刷新Scene面板。
我们在OnSceneGUI()调用它:
protected virtual void OnSceneGUI() { if (!map.m_EditorDrawGizmos) { return; } GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = map.m_EditorCellColor; // Scene面板左上角显示信息 Handles.BeginGUI(); { Rect areaRect = new Rect(50, 50, 200, 200); GUILayout.BeginArea(areaRect); { // 你的GUILayout代码 DrawHorizontalLabel("Object Name:", map.gameObject.name, textStyle); DrawHorizontalLabel("Map Name:", map.mapName, textStyle); DrawHorizontalLabel("Map Size:", map.width + "x" + map.height, textStyle); DrawHorizontalLabel("Cell Size:", map.grid.cellSize.x + "x" + map.grid.cellSize.y, textStyle); } GUILayout.EndArea(); } Handles.EndGUI(); // 立即刷新Scene面板 UpdateSceneGUI(); }
这样,在渲染Scene面板时,同时再次刷新它,就保证了每帧都运行。
6 判断Cell是否在地图内与修改绘制Cell
我们已经几乎完成MapGraph.cs的编写,还缺少判断选择的点是不是在地图内,在将来游戏中也是需要的。在RectInt中已经有这个方法。
/// <summary> /// 地图是否包含Position /// </summary> /// <param name="position"></param> /// <returns></returns> public bool Contains(Vector3Int position) { return mapRect.Contains(new Vector2Int(position.x, position.y)); }
接下来在EditorDrawCellGizmos()方法中,修改绘制Cell,让Scene面板更人性化。
修改前代码:
GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = m_EditorCellColor; Gizmos.color = m_EditorCellColor; Handles.Label(cellCenter - halfCellSize, (new Vector2Int(cellPostion.x, cellPostion.y)).ToString(), textStyle); Gizmos.DrawWireCube(cellCenter, grid.cellSize);
修改后代码:
/// 绘制当前鼠标下的Cell边框与Position /// 如果包含Cell,正常绘制 /// 如果不包含Cell,改变颜色,并多绘制一个叉 if (Contains(cellPostion)) { GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = m_EditorCellColor; Gizmos.color = m_EditorCellColor; Handles.Label(cellCenter - halfCellSize, (new Vector2Int(cellPostion.x, cellPostion.y)).ToString(), textStyle); Gizmos.DrawWireCube(cellCenter, grid.cellSize); } else { GUIStyle textStyle = new GUIStyle(); textStyle.normal.textColor = m_EditorErrorColor; Gizmos.color = m_EditorErrorColor; Handles.Label(cellCenter - halfCellSize, (new Vector2Int(cellPostion.x, cellPostion.y)).ToString(), textStyle); Gizmos.DrawWireCube(cellCenter, grid.cellSize); // 绘制Cell对角线 Vector3 from = cellCenter - halfCellSize; Vector3 to = cellCenter + halfCellSize; Gizmos.DrawLine(from, to); float tmpX = from.x; from.x = to.x; to.x = tmpX; Gizmos.DrawLine(from, to); }
最后来看一下我们的成果:
图 3 - 21初步完成地图编辑器
这样我们就初步完成了地图编辑器。记得在MapGraph的“Prefab”上添加我们的MapGraph.cs。
7 在菜单中创建我们的地图(Createin Menu)
我们虽然已经有Prefab,但如果在其它项目使用,还要重新添加脚本。我们来创建一个选项直接建立MapGraph,打开MapGraphEditor.cs文件,添加方法。
[MenuItem("GameObject/SRPG/Map Graph", priority = -1)] public static MapGraph CreateMapGraphGameObject() { GameObject mapGraph = new GameObject("MapGraph", typeof(Grid)); GameObject tilemap = new GameObject("Tilemap", typeof(Tilemap), typeof(TilemapRenderer)); tilemap.transform.SetParent(mapGraph.transform, false); Selection.activeObject = mapGraph; return mapGraph.AddComponent<MapGraph>(); }
这只是一个例子,你可以自己选择添加的物体与代码,并设置他们。
图 3 - 22菜单中创建MapGraph