一直觉着GTA的小地图很方便,在地图上的图标能够实时反映出各种任务点、设施等的方位,那么我也仿照它的地图系统做一个简陋的。

还有,提前说一下,这篇文章面向至少用UGUI做过按钮点击事件的读者,因为一些东西我就当大家都会了,会略过,而因兴趣刚下载Unity摸了两下的读者可能得先入门一下UGUI了。我使用的Unity版本为2019.2正式版。

按照惯例,先贴几个图展示一下成果,看看是不是大家想看到的功能。PS:我连地图图标素材都没有,就随便用了一些奇奇怪怪的图标,莫要见怪……

首先,展示一下小地图模式:

如上图所示,小地图上正确显示了图标的位置。接下来玩家往西或往西北走一段路:

往西走                                       往西北走

可以看到,似乎图标错位了,no no no,其实是“边缘滞留”效果,说白了,即使对象超出地图相机的视野范围,其相应图标也会在地图边缘的一定位置上显示。边缘如下白框Gizmos所示,边缘使得图标可以在离地图的真实边缘(相对于上一个“边缘”是真实边缘)一定距离后就不再靠近真实边缘了,这个偏移距离我称之为“边缘厚度”,当然,这个边缘厚度是可以调的。

哦佛阔死,有万众瞩目的圆形小地图模式:

圆形的半径当然也可以调啦。接下来看一下大地图模式,点一下地图旁边的“切换模式”,顾名思义,就是在小地图和大地图直接来回切换嘛。

如上图所示,大地图模式的地图相机视野更开阔。大地图模式下,可以拖拽地图,来浏览其它区域,还可以在地图上做标记。口述不清楚,还是来一张动图示范吧(^U^)ノ

其中,点击地图位置转变为世界坐标的功能,被封装成方法,方便其它情况使用,这里的点击地图来生成标记就是一种用法。

It's so cool, isn't it? 那么怎么实现呢?先说一下思路:

1、图标做成Prefab,通过2中的脚本在地图上动态生成,同时挂一个脚本,引用图标的Image组件和Button组件(我的设计是,图标是可以点击的,功能可选)。在下文它是MapIcon脚本;

2、需要生成图标的对象,挂一个脚本,专门用于处理指定图标。在下文它是MapIconHolder脚本;

3、地图使用额外的正交相机来获取图像,把该图像放到RenderTxeture上(PS:相信随便度娘一下小地图的制作,几乎都是这个方法吧?没错,这个方法真的很方便!)把相机的RenderTexture放进RawImage,这样就能在UI上看到主相机外其它相机的画面了。

4:、地图的RawImage也另外挂一个脚本,用来反馈地图拖拽、点击地图以进行转换世界坐标。在下文它是Map脚本;

5、需要地图管理器,用来创建和绘制图标、移动地图相机等等,所有与地图操作相关的功能都用这个类实现,给2、4和其它情况使用。在下文它是MapManager脚本。

那么来看看MapIcon是怎么实现的吧,其实它内容很少:

using UnityEngine;
using UnityEngine.UI;[RequireComponent(typeof(Image))]
public class MapIcon : MonoBehaviour
{[HideInInspector]public Image iconImage;public Button iconButton;[HideInInspector]public MapIconType iconType;private void Awake(){iconImage = GetComponent<Image>();if (!iconButton) iconButton = GetComponent<Button>();}
}
public enum MapIconType
{Normal,Main,Mark,Quest,
}

很少吧?我刚刚在上面说了,图标点击是可选功能,所以图标可以没有Button组件,就没在开头Require,而Image则是必须的。后面我还有个图标类型,类型功能顾名思义,但是目前我没设计相关功能。但是可以先说一下,至于为什么要HideInInpector,是因为图标只是最基本的地图组件,相对于其它地图组件,它没有有效的主动行为(在MonoBehaviour自带的方法中实现的东西,我称之为“主动行为”),在检视器界面随意修改iconType反而会影响实际效果。

在层级视图右键,UI,新建一个Image,把该脚本拖它到上面,然后编辑界面是酱紫的(PS:我尝鲜用了官方的中文预览包,所以组件名称是中文的,莫要见怪):

上图最下方的那个就是MapIcon脚本了(这不是废话吗-  -| |,大家没瞎~)。

好,那么就是MapIconHolder脚本了,用来对应MapIcon,它实现如下:

using UnityEngine;public class MapIconHolder : MonoBehaviour
{[Tooltip("游戏运行时修改无效。")]public Sprite icon;[Tooltip("游戏运行时修改无效。")]public Vector2 iconSize = new Vector2(48, 48);public bool drawOnWorldMap = true;public bool keepOnMap = true;//是否滞留在地图边缘[Tooltip("小于 0 时表示显示状态不受距离影响。")]public float maxValidDistance = -1;//当距离玩家多远时隐藏该图标?[HideInInspector]public float distanceSqr;//距离的平方,用于在进行距离计算时避免进行开方这种耗时的操作public bool forceHided;public MapIconType iconType;public MapIcon iconInstance;private void Awake(){distanceSqr = maxValidDistance * maxValidDistance;}void Start(){if (MapManager.Instance) MapManager.Instance.CreateMapIcon(this);}//以下三个方法用于在游戏时动态修改图标信息public void SetIconImage(Sprite icon){if (iconInstance) iconInstance.iconImage.overrideSprite = icon;}public void SetIconSize(Vector2 size){if (iconInstance) iconInstance.iconImage.rectTransform.sizeDelta = size;}public void SetIconType(MapIconType iconType){if (iconInstance) iconInstance.iconType = iconType;}public void ShowIcon(){if (forceHided) return;if (iconInstance && iconInstance.iconImage) iconInstance.iconImage.enabled = true;if (iconInstance && iconInstance.iconButton) iconInstance.iconButton.enabled = true;}public void HideIcon(){if (iconInstance && iconInstance.iconImage) iconInstance.iconImage.enabled = false;if (iconInstance && iconInstance.iconButton) iconInstance.iconButton.enabled = false;}private void OnDestroy(){if (MapManager.Instance) MapManager.Instance.RemoveMapIcon(this);}
}

一些我认为会有疑惑的内容,我写在注释里了。这个脚本中,主动行为仅仅是生成图标。选中需要图标示意的游戏对象,把该脚本拖上去或者随你了,就可以看到编辑界面:

其中,前面三项在游戏运行中就无法改变了,处于灰色的不可编辑状态,因为它们仅仅用于初始化地图图标。最大显示距离当然也修改无效,不过我忘记加上面的效果了。

可能复制粘贴的读者会有疑问:“为什么和我的界面不一样?是因为博主用了中文预览包的缘故吗?那我也装一个(^U^)ノ”

并不是,我专门自定义了该脚本的Inspector,并且放在了Editor文件夹里面(当然这个文件夹可以作为任意目录下的子文件夹,如 QuestSystem/Editor、MapSystem/Editor可同时存在):

using UnityEngine;
using UnityEditor;[CustomEditor(typeof(MapIconHolder))]
public class MapIconHolderInspector : Editor
{SerializedProperty icon;SerializedProperty iconSize;SerializedProperty iconType;SerializedProperty drawOnWorldMap;SerializedProperty keepOnMap;SerializedProperty maxValidDistance;SerializedProperty forceHided;private void OnEnable(){icon = serializedObject.FindProperty("icon");iconSize = serializedObject.FindProperty("iconSize");iconType = serializedObject.FindProperty("iconType");drawOnWorldMap = serializedObject.FindProperty("drawOnWorldMap");keepOnMap = serializedObject.FindProperty("keepOnMap");maxValidDistance = serializedObject.FindProperty("maxValidDistance");forceHided = serializedObject.FindProperty("forceHided");}public override void OnInspectorGUI(){serializedObject.Update();EditorGUI.BeginChangeCheck();if (Application.isPlaying) GUI.enabled = false;EditorGUILayout.PropertyField(icon, new GUIContent("图标"));EditorGUILayout.PropertyField(iconSize, new GUIContent("图标大小"));EditorGUILayout.IntPopup(iconType, new GUIContent[] { new GUIContent("普通"), new GUIContent("标记"), new GUIContent("任务") }, new int[] { 0, 2, 3 }, new GUIContent("图标类型"));if (Application.isPlaying) GUI.enabled = true;EditorGUILayout.PropertyField(keepOnMap, new GUIContent("保持显示"));EditorGUILayout.PropertyField(drawOnWorldMap, new GUIContent("在大地图上显示"));EditorGUILayout.PropertyField(maxValidDistance, new GUIContent("最大有效显示距离"));EditorGUILayout.PropertyField(forceHided, new GUIContent("强制隐藏"));if (EditorGUI.EndChangeCheck()) serializedObject.ApplyModifiedProperties();}
}

自定义Inspector的学问可大的去了,也不是我这个“Unity2D独立开发手记”系列的研究方向,而且代码行数与脚本变量的数量成正比,我就不特意贴上来占用版面了,想了解的可以在我的GitHub上看。这里也仅仅是演示一下,看看是怎么实现自定义的,下文再也不会贴这种自定义了。

在MapIconHolder中,已经可以看到MapManager中相关的方法了,好,那么接下来就解开它的神秘面纱(磊了磊了,又臭又长的脚本它磊了):

using UnityEngine;
using System.Collections.Generic;[DisallowMultipleComponent]
public class MapManager : SingletonMonoBehaviour<MapManager>
{[SerializeField]private MapUI UI;[SerializeField]private UpdateMode updateMode;[SerializeField]private Transform player;[SerializeField]private Sprite playerIcon;[SerializeField]private Vector2 playerIconSize = new Vector2(64, 64);private MapIcon playerIconInsatance;[SerializeField]private Sprite defaultMarkIcon;[SerializeField]private Vector2 defaultMarkSize = new Vector2(64, 64);[SerializeField]private new Camera camera;[SerializeField]private RenderTexture targetTexture;[SerializeField]private LayerMask mapRenderMask = ~0;[SerializeField]private bool use2D = true;[SerializeField, Tooltip("否则旋转图标。")]private bool rotateMap;[SerializeField]private bool circle;[SerializeField, Tooltip("此值为地图Rect宽度、高度两者中较小值的倍数。"), Range(0, 0.5f)]private float edgeSize;[SerializeField, Tooltip("此值为地图Rect宽度、高度两者中较小值的倍数。"), Range(0.5f, 1)]private float radius = 1;[SerializeField, Tooltip("此值为地图Rect宽度、高度两者中较小值的倍数。"), Range(0, 0.5f)]private float worldEdgeSize;[SerializeField]private bool isViewingWorldMap;[SerializeField]private float dragSensitivity = 0.135f;[SerializeField, Tooltip("小于等于 0 时表示不动画。")]private float animationSpeed = 5;private bool AnimateAble => animationSpeed > 0 && miniModeInfo.mapAnchoreMax == worldModeInfo.mapAnchoreMax && miniModeInfo.mapAnchoreMin == worldModeInfo.mapAnchoreMin&& miniModeInfo.windowAnchoreMax == worldModeInfo.windowAnchoreMax && miniModeInfo.windowAnchoreMin == worldModeInfo.windowAnchoreMin;private bool isSwitching;private float switchTime;private float startSizeOfCamForMap;private Vector2 startPositionOfMap;private Vector2 startSizeOfMapWindow;private Vector2 startSizeOfMap;[SerializeField]private MapModeInfo miniModeInfo = new MapModeInfo();[SerializeField]private MapModeInfo worldModeInfo = new MapModeInfo();private readonly Dictionary<MapIconHolder, MapIcon> iconsWithHolder = new Dictionary<MapIconHolder, MapIcon>();private readonly List<MapIconWithoutHolder> iconsWithoutHolder = new List<MapIconWithoutHolder>();#region 地图图标相关public MapIcon CreateMapIcon(MapIconHolder holder){if (!UI || !UI.gameObject) return null;MapIcon icon = ObjectPool.Instance.Get(UI.iconPrefb.gameObject, UI.iconsParent).GetComponent<MapIcon>();icon.iconImage.rectTransform.pivot = new Vector2(0.5f, 0.5f);icon.iconImage.overrideSprite = holder.icon;icon.iconImage.rectTransform.sizeDelta = holder.iconSize;holder.iconInstance = icon;iconsWithHolder.TryGetValue(holder, out MapIcon iconFound);if (iconFound != null) holder.iconInstance = icon;else iconsWithHolder.Add(holder, icon);return icon;}public MapIcon CearteMapIcon(Sprite iconSprite, Vector2 size, Vector3 worldPosition, bool keepOnMap){if (!UI || !UI.gameObject) return null;MapIcon icon = ObjectPool.Instance.Get(UI.iconPrefb.gameObject, UI.iconsParent).GetComponent<MapIcon>();icon.iconImage.overrideSprite = iconSprite;icon.iconImage.rectTransform.sizeDelta = size;iconsWithoutHolder.Add(new MapIconWithoutHolder(worldPosition, icon, keepOnMap));return icon;}public MapIcon CreateMark(Vector3 worldPosition, bool keepOnMap){return CearteMapIcon(defaultMarkIcon, defaultMarkSize, worldPosition, keepOnMap);}public MapIcon CreateMarkByMousePosition(Vector3 mousePosition){return CreateMark(MapPointToWorldPoint(mousePosition), true);}public void RemoveMapIcon(MapIconHolder holder){if (!holder) return;holder.iconInstance = null;iconsWithHolder.TryGetValue(holder, out MapIcon iconFound);if (iconFound != null){if (ObjectPool.Instance) ObjectPool.Instance.Put(iconFound.gameObject);iconsWithHolder.Remove(holder);}}public void RemoveMapIcon(MapIcon icon){iconsWithoutHolder.RemoveAll(x => x.mapIcon == icon);ObjectPool.Instance.Put(icon.gameObject);}public void RemoveMapIcon(Vector3 worldPosition){foreach (var icon in iconsWithoutHolder){if (icon.worldPosition == worldPosition)ObjectPool.Instance.Put(icon.mapIcon.gameObject);}iconsWithoutHolder.RemoveAll(x => x.worldPosition == worldPosition);}private void DrawMapIcons(){if (!UI || !UI.gameObject) return;camera.orthographic = true;camera.tag = "MapCamera";camera.cullingMask = mapRenderMask;foreach (var iconKvp in iconsWithHolder)if (!iconKvp.Key.forceHided && (isViewingWorldMap && iconKvp.Key.drawOnWorldMap || !isViewingWorldMap && (iconKvp.Key.maxValidDistance <= 0|| iconKvp.Key.maxValidDistance > 0 && iconKvp.Key.distanceSqr >= Vector3.SqrMagnitude(iconKvp.Key.transform.position - player.position)))){iconKvp.Key.ShowIcon();DrawMapIcon(iconKvp.Key.transform.position, iconKvp.Value.transform, iconKvp.Key.keepOnMap);}else iconKvp.Key.HideIcon();foreach (var icon in iconsWithoutHolder)DrawMapIcon(icon.worldPosition, icon.mapIcon.transform, icon.keepOnMap);}private void DrawMapIcon(Vector3 worldPosition, Transform iconTrans, bool keepOnMap){if (!UI || !UI.gameObject) return;//把相机视野内的世界坐标归一化为一个裁剪正方体中的坐标,其边长为1,就是说所有视野内的坐标都变成了x、z、y分量都在(0,1)以内的裁剪坐标//(图形学基础,不知所云的读者得加强一下)Vector3 viewportPoint = camera.WorldToViewportPoint(worldPosition);//这一步用于修正UI因设备分辨率不一样而进行缩放后实际Rect信息变了从而产生的问题Rect screenSpaceRect = ZetanUtilities.GetScreenSpaceRect(UI.mapRect);Vector3[] corners = new Vector3[4];UI.mapRect.GetWorldCorners(corners);//获取四个顶点的位置,顶点序号//  1 ┏━┓ 2//  0 ┗━┛ 3//根据归一化的裁剪坐标,转化为相对于地图的坐标Vector3 screenPos = new Vector3(viewportPoint.x * screenSpaceRect.width + corners[0].x, viewportPoint.y * screenSpaceRect.height + corners[0].y, 0);if (keepOnMap){//以窗口的Rect为范围基准而不是地图的screenSpaceRect = ZetanUtilities.GetScreenSpaceRect(UI.mapWindowRect);float size = (screenSpaceRect.width < screenSpaceRect.height ? screenSpaceRect.width : screenSpaceRect.height) / 2;//地图的一半尺寸UI.mapWindowRect.GetWorldCorners(corners);if (circle && !isViewingWorldMap){//以下不使用UI.mapWindowRect.position,是因为该position值会受轴心(UI.mapWindowRect.pivot)位置的影响而使得最后的结果出现偏移Vector3 realCenter = ZetanUtilities.CenterBetween(corners[0], corners[2]);Vector3 positionOffset = Vector3.ClampMagnitude(screenPos - realCenter, radius * size);screenPos = realCenter + positionOffset;}else{float edgeSize = (isViewingWorldMap ? worldEdgeSize : this.edgeSize) * size;screenPos.x = Mathf.Clamp(screenPos.x, corners[0].x + edgeSize, corners[2].x - edgeSize);screenPos.y = Mathf.Clamp(screenPos.y, corners[0].y + edgeSize, corners[1].y - edgeSize);}}iconTrans.position = screenPos;}private void FollowPlayer(){if (!player || !playerIconInsatance) return;DrawMapIcon(isViewingWorldMap ? player.position : camera.transform.position, playerIconInsatance.transform, true);playerIconInsatance.transform.SetSiblingIndex(playerIconInsatance.transform.childCount - 1);if (!rotateMap){if (use2D)playerIconInsatance.transform.eulerAngles = new Vector3(playerIconInsatance.transform.eulerAngles.x, playerIconInsatance.transform.eulerAngles.y, player.eulerAngles.z);elseplayerIconInsatance.transform.eulerAngles = new Vector3(playerIconInsatance.transform.eulerAngles.x, player.eulerAngles.y, playerIconInsatance.transform.eulerAngles.z);}else{if (use2D) camera.transform.eulerAngles = new Vector3(0, 0, player.eulerAngles.z);else camera.transform.eulerAngles = new Vector3(camera.transform.eulerAngles.x, player.eulerAngles.y, camera.transform.eulerAngles.z);}if (!isViewingWorldMap) camera.transform.position = new Vector3(player.position.x, use2D ? player.position.y : camera.transform.position.y,use2D ? camera.transform.position.z : player.position.z);}public Vector3 MapPointToWorldPoint(Vector3 mousePosition){Rect screenSpaceRect = ZetanUtilities.GetScreenSpaceRect(UI.mapRect);Vector3[] corners = new Vector3[4];UI.mapRect.GetWorldCorners(corners);Vector2 viewportPoint = new Vector2((mousePosition.x - corners[0].x) / screenSpaceRect.width, (mousePosition.y - corners[0].y) / screenSpaceRect.height);Vector3 worldPosition = camera.ViewportToWorldPoint(viewportPoint);return use2D ? new Vector3(worldPosition.x, worldPosition.y) : worldPosition;}#endregion#region 地图切换相关public void SwitchMapMode(){if (!UI || !UI.gameObject) return;isViewingWorldMap = !isViewingWorldMap;if (!isViewingWorldMap)//从大向小切换{if (animationSpeed > 0){UI.mapWindowRect.anchorMin = miniModeInfo.windowAnchoreMin;UI.mapWindowRect.anchorMax = miniModeInfo.windowAnchoreMax;UI.mapRect.anchorMin = miniModeInfo.mapAnchoreMin;UI.mapRect.anchorMax = miniModeInfo.mapAnchoreMax;}else ToMiniMap();}else{if (animationSpeed > 0){UI.mapWindowRect.anchorMin = worldModeInfo.windowAnchoreMin;UI.mapWindowRect.anchorMax = worldModeInfo.windowAnchoreMax;UI.mapRect.anchorMin = worldModeInfo.mapAnchoreMin;UI.mapRect.anchorMax = worldModeInfo.mapAnchoreMax;}else ToWorldMap();}if (animationSpeed > 0){isSwitching = true;switchTime = 0;startSizeOfCamForMap = camera.orthographicSize;startPositionOfMap = UI.mapWindowRect.anchoredPosition;startSizeOfMapWindow = UI.mapWindowRect.rect.size;startSizeOfMap = UI.mapRect.rect.size;}}private void AnimateSwitching(){if (!UI || !UI.gameObject || !AnimateAble) return;switchTime += Time.deltaTime * animationSpeed;if (isViewingWorldMap){if (camera.orthographicSize < worldModeInfo.sizeOfCam) AnimateTo(worldModeInfo);else ToWorldMap();}else{if (camera.orthographicSize > miniModeInfo.sizeOfCam) AnimateTo(miniModeInfo);else ToMiniMap();}}private void AnimateTo(MapModeInfo modeInfo){if (!UI || !UI.gameObject) return;camera.orthographicSize = Mathf.Lerp(startSizeOfCamForMap, modeInfo.sizeOfCam, switchTime);UI.mapWindowRect.anchoredPosition = Vector3.Lerp(startPositionOfMap, modeInfo.anchoredPosition, switchTime);UI.mapRect.sizeDelta = Vector2.Lerp(startSizeOfMap, modeInfo.sizeOfMap, switchTime);UI.mapWindowRect.sizeDelta = Vector2.Lerp(startSizeOfMapWindow, modeInfo.sizeOfWindow, switchTime);}public void ToMiniMap(){isSwitching = false;switchTime = 0;isViewingWorldMap = false;SetInfoFrom(miniModeInfo);}public void ToWorldMap(){isSwitching = false;switchTime = 0;isViewingWorldMap = true;SetInfoFrom(worldModeInfo);}private void SetInfoFrom(MapModeInfo modeInfo){if (!UI || !UI.gameObject) return;camera.orthographicSize = modeInfo.sizeOfCam;UI.mapWindowRect.anchorMin = modeInfo.windowAnchoreMin;UI.mapWindowRect.anchorMax = modeInfo.windowAnchoreMax;UI.mapRect.anchorMin = modeInfo.mapAnchoreMin;UI.mapRect.anchorMax = modeInfo.mapAnchoreMax;UI.mapWindowRect.anchoredPosition = modeInfo.anchoredPosition;UI.mapRect.sizeDelta = modeInfo.sizeOfMap;UI.mapWindowRect.sizeDelta = modeInfo.sizeOfWindow;}public void SetCurrentAsMiniMap(){if (!UI || !UI.gameObject || isViewingWorldMap) return;if (camera) miniModeInfo.sizeOfCam = camera.orthographicSize;else Debug.LogError("地图相机不存在!");if (UI && UI.mapWindowRect) CopyInfoTo(miniModeInfo);else Debug.LogError("地图UI不存在或未编辑完整!");}public void SetCurrentAsWorldMap(){if (!UI || !UI.gameObject || !isViewingWorldMap) return;if (camera) worldModeInfo.sizeOfCam = camera.orthographicSize;else Debug.LogError("地图相机不存在!");if (UI && UI.mapWindowRect) CopyInfoTo(worldModeInfo);else Debug.LogError("地图UI不存在或未编辑完整!");}private void CopyInfoTo(MapModeInfo modeInfo){if (!UI || !UI.gameObject) return;modeInfo.windowAnchoreMin = UI.mapWindowRect.anchorMin;modeInfo.windowAnchoreMax = UI.mapWindowRect.anchorMax;modeInfo.mapAnchoreMin = UI.mapRect.anchorMin;modeInfo.mapAnchoreMax = UI.mapRect.anchorMax;modeInfo.anchoredPosition = UI.mapWindowRect.anchoredPosition;modeInfo.sizeOfWindow = UI.mapWindowRect.sizeDelta;modeInfo.sizeOfMap = UI.mapRect.sizeDelta;}public void DragWorldMap(Vector3 dir){if (isViewingWorldMap)camera.transform.Translate(new Vector3(dir.x, use2D ? dir.y : 0, use2D ? 0 : dir.y) * -dragSensitivity / (Application.platform == RuntimePlatform.Android ? 2 : 1));}#endregion#region MonoBehaviourprivate void Start(){ToMiniMap();playerIconInsatance = ObjectPool.Instance.Get(UI.iconPrefb.gameObject, UI.iconsParent).GetComponent<MapIcon>();playerIconInsatance.iconImage.overrideSprite = playerIcon;playerIconInsatance.iconImage.rectTransform.sizeDelta = playerIconSize;camera.targetTexture = targetTexture;UI.mapImage.texture = targetTexture;}private void Update(){if (updateMode == UpdateMode.Update) DrawMapIcons();if (isSwitching) AnimateSwitching();}private void LateUpdate(){if (updateMode == UpdateMode.LateUpdate) DrawMapIcons();}private void FixedUpdate(){if (updateMode == UpdateMode.FixedUpdate) DrawMapIcons();FollowPlayer();//放在FixedUpdate()可以有效防止主图标抖动}private void OnDrawGizmos(){if (!UI || !UI.gameObject || !UI.mapWindowRect) return;Rect screenSpaceRect = ZetanUtilities.GetScreenSpaceRect(UI.mapWindowRect);Vector3[] corners = new Vector3[4];UI.mapWindowRect.GetWorldCorners(corners);if (circle && !isViewingWorldMap){float radius = (screenSpaceRect.width < screenSpaceRect.height ? screenSpaceRect.width : screenSpaceRect.height) / 2 * this.radius;ZetanUtilities.DrawGizmosCircle(ZetanUtilities.CenterBetween(corners[0], corners[2]), radius, radius / 1000, Color.white, false);}else{float edgeSize = isViewingWorldMap ? worldEdgeSize : this.edgeSize;Vector3 size = new Vector3(screenSpaceRect.width - edgeSize * (screenSpaceRect.width < screenSpaceRect.height ? screenSpaceRect.width : screenSpaceRect.height),screenSpaceRect.height - edgeSize * (screenSpaceRect.width < screenSpaceRect.height ? screenSpaceRect.width : screenSpaceRect.height), 0);Gizmos.DrawWireCube(ZetanUtilities.CenterBetween(corners[0], corners[2]), size);}}#endregionprivate class MapIconWithoutHolder{public Vector3 worldPosition;public MapIcon mapIcon;public bool keepOnMap;public MapIconWithoutHolder(Vector3 worldPosition, MapIcon mapIcon, bool keepOnMap){this.worldPosition = worldPosition;this.mapIcon = mapIcon;this.keepOnMap = keepOnMap;}}[System.Serializable]public class MapModeInfo{public float sizeOfCam;public Vector2 windowAnchoreMin;public Vector2 windowAnchoreMax;public Vector2 mapAnchoreMin;public Vector2 mapAnchoreMax;public Vector2 anchoredPosition;public Vector2 sizeOfWindow;public Vector2 sizeOfMap;}
}

我++……又没高亮了!算了,看得到就行,看高亮还是老老实实去GitHub看吧,这里动不动就有这种Bug我真是佛了,以前以为是代码过长,不曾想有时候不到50行的也会,加什么“···c# ···”也没用,无语……

核心代码DrawMapIcon中,有个修正实际Rect大小的函数

Rect screenSpaceRect = ZetanUtilities.GetScreenSpaceRect(UI.mapRect);

它被我做成静态方法了,实现如下:

    public static Rect GetScreenSpaceRect(RectTransform rectTransform){Vector2 size = Vector2.Scale(rectTransform.rect.size, rectTransform.lossyScale);float x = rectTransform.position.x + rectTransform.anchoredPosition.x;float y = Screen.height - (rectTransform.position.y - rectTransform.anchoredPosition.y);return new Rect(x, y, size.x, size.y);}

然后,自定义了一下Inspector,在层级视图中新建一个MapManager对象,给它添加MapManager脚本,编辑界面如下:

然后,这次就破天荒地记一下UI的搭建吧。

1、首先右键新建一个UI->Canvas,把ScaleMode缩放模式改成第二项(其它项我不知道有没有Bug);

2、然后右键新建一个空的子对象,命名为MapUI,点击RectTransform偏左上那个带几个箭头的方框改锚点,会弹出一个浮窗,按住Alt键,点最后最右下的那个蓝色的,把MapUI平铺到屏幕;

i、新建一个脚本,叫MapUI,我一般都用这种方法,分离UI和Manager,需要的时候给UI“换皮”。脚本如下:

using UnityEngine;
using UnityEngine.UI;public class MapUI : MonoBehaviour
{public CanvasGroup mapWindow;public RectTransform mapWindowRect;public MapIcon iconPrefb;public RectTransform iconsParent;public RectTransform mapRect;public RawImage mapImage;public Button @switch;private void Awake(){@switch.onClick.AddListener(MapManager.Instance.SwitchMapMode);}
}

ii、当然啦,得把该脚本添加给MapUI,然后完形填空,最后MapUI就是这样了:

3、右键MapUI新建一个UI->Image子对象,放到窗口左上角或者随意了,重命名为MapWindow,把锚点改到左上角或者前面放的位置,调整大小到合适的值,等会儿将作为小地图状态。最后,给它加一个CanvasGroup组件(在下图是“画布组”);

4、右键MapWindow新建一个UI->Image子对象,重命名为MapMask,按1中的做法把它平铺到MapWindow,然后给他加一个Mask组件,并把那个唯一的复选框去勾;

5、右键MapMask新建一个UI->RawImage对象,重命名为Map,保持锚点不变,在Asset视图右键新建一个RenderTexture(渲染器纹理),重命名为Map,把它拖到RawImage中的第一个框中,写一个Map脚本,添加给它;

using UnityEngine;
using UnityEngine.EventSystems;public class Map : MonoBehaviour, /*IBeginDragHandler,*/ IDragHandler, /*IEndDragHandler,*/ IPointerClickHandler
{/*public void OnBeginDrag(PointerEventData eventData){}*/public void OnDrag(PointerEventData eventData){if ((Application.platform == RuntimePlatform.Android) || eventData.button == PointerEventData.InputButton.Right)MapManager.Instance.DragWorldMap(eventData.delta);}/*public void OnEndDrag(PointerEventData eventData){}*/public void OnPointerClick(PointerEventData eventData){if (eventData.clickCount > 1){MapManager.Instance.CreateMarkByMousePosition(Input.mousePosition);}}
}

6、右键Map新建一个空对象,重命名为IconsParent,它将作为所有图标的父对象,方便一键隐藏显示所有图标;

7、右键MapWindow新建一个UI->Button对象,重命名为Switch,将用于点击切换地图模式。

最后,得到的UI层级是酱紫的:

图片跟我上面步骤说的有些许不一样,因为我多加了一个可有可无的边框。最后,把MapUI的子对象拖入MapUI脚本中的相应方框里,然后把MapUI拖到MapManager的UI方框里。

新建一个相机,重命名为MapCamera,把Tag改成不是“MainCamera”,除了Camera组件外把其它的组件全Remove。以下操作可选:把相机的投影Project改成O开头那个,也就是正交相机。把上面那个名为Map的RenderTexture拖到相机的Target texture方框里。不出意外,此时UI里就可以看到该相机的画面了。调整相机的Size,来选取一个合适的值使得小地图看起来像小地图。

返回MapManager,如果上了我的GitHub拿了源码,得到我自定义的Inspector,那就把“当前是大地图模式”的复选框去勾,然后点“以当前状态作为小地图模式”,点了以后,如果有展开下面的“小地图模式信息”,可以看到里面的数值变了。如果没有自定义Inspector,那就得自己去到UI记下RectTransform中的信息,然后再返回MapManager填写相应的ModeInfo,很原始吧?

好了,返回MapUI,调整窗口MapWindow大小位置到合适的值,调整Map大小到合适的值,将用于大地图状态,再去到MapCamera把它的Size调到合适大地图画面的值。

再次返回MapManager,勾选“当前是大地图模式”,然后点击“以当前状态作为大地图”。

把MapCamera拖到MapManager的相机框,再把那个名为Map的RenderTextrue拖到“采样贴图”框里,最后再设置一下跟随对象、主图标和默认标记图标,好像就万事俱备只欠东风了。好,运行游戏,就可以看到文章开头的效果了。至于地图的缩放,我这里没有实现,方法很简单,就是调节地图正交相机的Size即camera.orthographicSize,自己动手,丰衣足食。最后,提醒一下,我有一些稍微影响性能的多余代码,只是为了防止在开发时误操作,最终版本将会删减,至于是哪些,内行看门道。

2019年9月8日更新:在MapManager里提到的图标滞留边缘效果,其实不是以窗口为基准,而是以地图的遮罩为基准,已修复,不过文章没更新,详情请移步我的GitHub。

2019年12月25日更新:新增带上下限的缩放功能,图标可附带范围圈。

缩放功能用Map和MapManager结合实现,Map脚本增量更新如下:

using UnityEngine;
using UnityEngine.EventSystems;public class Map : MonoBehaviour, IDragHandler, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{public void OnDrag(PointerEventData eventData){if ((Application.platform == RuntimePlatform.Android) || eventData.button == PointerEventData.InputButton.Right)MapManager.Instance.DragWorldMap(-eventData.delta);}public void OnPointerClick(PointerEventData eventData){if (!MapManager.Instance) return;
#if UNITY_STANDALONEif (eventData.clickCount > 1)MapManager.Instance.CreateMarkByMousePosition(eventData.position);
#elif UNITY_ANDROIDif (eventData.button == PointerEventData.InputButton.Left){if (clickCount < 1) isClick = true;if (clickTime <= 0.2f) clickCount++;if (clickCount > 1){if (MapManager.Instance.IsViewingWorldMap) MapManager.Instance.CreateMarkByMousePosition(eventData.position);isClick = false;clickCount = 0;clickTime = 0;}}
#endif}bool canZoom;public void OnPointerEnter(PointerEventData eventData){
#if UNITY_STANDALONE
#endifcanZoom = true;}public void OnPointerExit(PointerEventData eventData){
#if UNITY_STANDALONE
#endifcanZoom = false;}private void Update(){if (canZoom) MapManager.Instance.ZoomMap(Input.mouseScrollDelta.y);}#if UNITY_ANDROIDprivate float clickTime;private int clickCount;private bool isClick;private void FixedUpdate(){if (isClick){clickTime += Time.fixedDeltaTime;if (clickTime > 0.2f){isClick = false;clickCount = 0;clickTime = 0;}}}
#endif
}

MapManager脚本新增字段:

private Vector2 zoomLimit;

新增缩放方法:

public void ZoomMap(float value)
{Camera.orthographicSize = Mathf.Clamp(Camera.orthographicSize - value, zoomLimit.x, zoomLimit.y);
}

MapModeInfo类更新如下:

    [Serializable]public class MapModeInfo{public float sizeOfCam;public float minZoomOfCam;public float maxZoomOfCam;public Vector2 windowAnchoreMin;public Vector2 windowAnchoreMax;public Vector2 mapAnchoreMin;public Vector2 mapAnchoreMax;public Vector2 anchoredPosition;public Vector2 sizeOfWindow;public Vector2 sizeOfMap;}

SetInfoFrom方法更新如下:

    private void SetInfoFrom(MapModeInfo modeInfo){if (!UI || !UI.gameObject) return;Camera.orthographicSize = modeInfo.sizeOfCam;zoomLimit.x = modeInfo.minZoomOfCam;zoomLimit.y = modeInfo.maxZoomOfCam;UI.mapWindowRect.anchorMin = modeInfo.windowAnchoreMin;UI.mapWindowRect.anchorMax = modeInfo.windowAnchoreMax;UI.mapRect.anchorMin = modeInfo.mapAnchoreMin;UI.mapRect.anchorMax = modeInfo.mapAnchoreMax;UI.mapWindowRect.anchoredPosition = modeInfo.anchoredPosition;UI.mapRect.sizeDelta = modeInfo.sizeOfMap;UI.mapWindowRect.sizeDelta = modeInfo.sizeOfWindow;}

范围圈功能用MapIconHolder、MapIcon、MapManager脚本结合实现。

MapIconHolder脚本更新如下:

using UnityEngine;
using System.Collections;public class MapIconHolder : MonoBehaviour
{public Sprite icon;public Vector2 iconSize = new Vector2(48, 48);public bool drawOnWorldMap = true;public bool keepOnMap = true;[SerializeField, Tooltip("小于零时表示显示状态不受距离影响。游戏运行时修改无效。")]private float maxValidDistance = -1;[HideInInspector]public float distanceSqr;public bool forceHided;public bool showRange;public Color rangeColor = new Color(1, 1, 1, 0.5f);public float rangeSize = 144;public MapIconType iconType;public MapIcon iconInstance;public bool AutoHide => maxValidDistance > 0;private void Awake(){distanceSqr = maxValidDistance * maxValidDistance;StartCoroutine(UpdateSizeAndColor());}void Start(){if (MapManager.Instance) MapManager.Instance.CreateMapIcon(this);}public void SetIconValidDistance(float distance){maxValidDistance = distance;distanceSqr = maxValidDistance * maxValidDistance;}public void ShowIcon(float zoom){if (forceHided) return;if (iconInstance){if (iconInstance.iconImage) ZetanUtil.SetActive(iconInstance.iconImage.gameObject, true);if (iconInstance.iconRange)if (showRange){ZetanUtil.SetActive(iconInstance.iconRange.gameObject, true);iconInstance.iconRange.color = rangeColor;if (iconInstance.iconRange) iconInstance.iconRange.rectTransform.sizeDelta = new Vector2(rangeSize, rangeSize) * zoom;}else ZetanUtil.SetActive(iconInstance.iconRange.gameObject, false);}}public void HideIcon(){if (iconInstance){if (iconInstance.iconImage) ZetanUtil.SetActive(iconInstance.iconImage.gameObject, false);if (iconInstance.iconRange) ZetanUtil.SetActive(iconInstance.iconRange.gameObject, false);}}readonly WaitForSeconds WaitForSeconds = new WaitForSeconds(0.2f);private IEnumerator UpdateSizeAndColor(){while (true){if (iconInstance){iconInstance.iconImage.overrideSprite = icon;iconInstance.iconImage.rectTransform.sizeDelta = iconSize;iconInstance.iconType = iconType;yield return WaitForSeconds;}else yield return new WaitUntil(() => iconInstance);}}private void OnDestroy(){if (MapManager.Instance) MapManager.Instance.RemoveMapIcon(this);}
}

MapIcon脚本更新如下:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using System.Collections;public class MapIcon : MonoBehaviour, IPointerClickHandler,IPointerDownHandler, IPointerUpHandler,IPointerEnterHandler, IPointerExitHandler
{[HideInInspector]public Image iconImage;[HideInInspector]public Image iconRange;[HideInInspector]public UnityEvent onClick = new UnityEvent();[HideInInspector]public UnityEvent onEnter = new UnityEvent();[HideInInspector]public MapIconType iconType;private void OnRightClick(){if (iconType == MapIconType.Mark)MapManager.Instance.RemoveMapIcon(this);}public void OnPointerClick(PointerEventData eventData){if (eventData.button == PointerEventData.InputButton.Left) onClick?.Invoke();if (eventData.button == PointerEventData.InputButton.Right) OnRightClick();}public void OnPointerDown(PointerEventData eventData){
#if UNITY_ANDROIDif (eventData.button == PointerEventData.InputButton.Left){if (pressCoroutine != null) StopCoroutine(pressCoroutine);pressCoroutine = StartCoroutine(Press());}
#endif}public void OnPointerUp(PointerEventData eventData){
#if UNITY_ANDROIDif (pressCoroutine != null) StopCoroutine(pressCoroutine);
#endif}public void OnPointerEnter(PointerEventData eventData){onEnter?.Invoke();}public void OnPointerExit(PointerEventData eventData){
#if UNITY_ANDROIDif (pressCoroutine != null) StopCoroutine(pressCoroutine);
#endif}private void Awake(){iconImage = transform.Find("Icon").GetComponent<Image>();iconRange = transform.Find("Range").GetComponent<Image>();if (iconRange) iconRange.raycastTarget = false;}#if UNITY_ANDROIDreadonly WaitForFixedUpdate WaitForFixedUpdate = new WaitForFixedUpdate();Coroutine pressCoroutine;IEnumerator Press(){float touchTime = 0;bool isPress = true;while (isPress){touchTime += Time.fixedDeltaTime;if (touchTime >= 0.5f){OnRightClick();yield break;}yield return WaitForFixedUpdate;}}
#endif
}
public enum MapIconType
{Normal,Main,Mark,Quest,
}

MapManager的两个相关方法更新如下:

    private void DrawMapIcons(){if (!UI || !UI.gameObject) return;Camera.orthographic = true;Camera.tag = "MapCamera";Camera.cullingMask = mapRenderMask;foreach (var iconKvp in iconsWithHolder){MapIconHolder holder = iconKvp.Key;if (!holder.forceHided && (isViewingWorldMap && holder.drawOnWorldMap || !isViewingWorldMap && (!holder.AutoHide|| holder.AutoHide && holder.distanceSqr >= Vector3.SqrMagnitude(holder.transform.position - player.position)))){holder.ShowIcon(IsViewingWorldMap ? (worldModeInfo.sizeOfCam / Camera.orthographicSize) : (miniModeInfo.sizeOfCam / Camera.orthographicSize));DrawMapIcon(holder.transform.position, iconKvp.Value, holder.keepOnMap);}else holder.HideIcon();}foreach (var icon in iconsWithoutHolder)DrawMapIcon(icon.worldPosition, icon.mapIcon, icon.keepOnMap);}private void DrawMapIcon(Vector3 worldPosition, MapIcon icon, bool keepOnMap){if (!UI || !UI.gameObject) return;//把相机视野内的世界坐标归一化为一个裁剪正方体中的坐标,其边长为1,就是说所有视野内的坐标都变成了x、z、y分量都在(0,1)以内的裁剪坐标Vector3 viewportPoint = Camera.WorldToViewportPoint(worldPosition);//这一步用于修正UI因设备分辨率不一样,在进行缩放后实际Rect信息变了而产生的问题Rect screenSpaceRect = ZetanUtil.GetScreenSpaceRect(UI.mapRect);//获取四个顶点的位置,顶点序号//  1 ┏━┓ 2//  0 ┗━┛ 3Vector3[] corners = new Vector3[4];UI.mapRect.GetWorldCorners(corners);//根据归一化的裁剪坐标,转化为相对于地图的坐标Vector3 screenPos = new Vector3(viewportPoint.x * screenSpaceRect.width + corners[0].x, viewportPoint.y * screenSpaceRect.height + corners[0].y, 0);Vector3 rangePos = screenPos;if (keepOnMap){//以遮罩的Rect为范围基准而不是地图的screenSpaceRect = ZetanUtil.GetScreenSpaceRect(UI.mapMaskRect);float size = (screenSpaceRect.width < screenSpaceRect.height ? screenSpaceRect.width : screenSpaceRect.height) / 2;//地图的一半尺寸UI.mapWindowRect.GetWorldCorners(corners);if (circle && !isViewingWorldMap){//以下不使用UI.mapMaskRect.position,是因为该position值会受轴心(UI.mapMaskRect.pivot)位置的影响而使得最后的结果出现偏移Vector3 realCenter = ZetanUtil.CenterBetween(corners[0], corners[2]);Vector3 positionOffset = Vector3.ClampMagnitude(screenPos - realCenter, radius * size);screenPos = realCenter + positionOffset;}else{float edgeSize = (isViewingWorldMap ? worldEdgeSize : this.edgeSize) * size;screenPos.x = Mathf.Clamp(screenPos.x, corners[0].x + edgeSize, corners[2].x - edgeSize);screenPos.y = Mathf.Clamp(screenPos.y, corners[0].y + edgeSize, corners[1].y - edgeSize);}}icon.transform.position = screenPos;if (icon.iconRange) icon.iconRange.transform.position = rangePos;}

阔别三个月,再次敲起喜欢的代码,实乃心旷神怡。

Unity 2D独立开发手记(九):UGUI仿GTA地图系统相关推荐

  1. Unity 2D独立开发手记(五):通用任务系统

    一年多没写博了,因为迫不得已转行,这破游戏也搁置了好久,过完年也有一个月了,回来找找感觉.那就记录一下任务系统的开发吧,方便以后回忆.(2022年3月注:文章中的任务系统太旧了,仅供思路参考,获取新版 ...

  2. Unity 2D游戏开发快速入门第1章创建一个简单的2D游戏

    Unity 2D游戏开发快速入门第1章创建一个简单的2D游戏 即使是现在,很多初学游戏开发的同学,在谈到Unity的时候,依然会认为Unity只能用于制作3D游戏的.实际上,Unity在2013年发布 ...

  3. Unity 2D 游戏开发解决方案大全

    Unity 2D 游戏开发解决方案大全 一些官方腔 这篇文章会是一个大纲模式,致力于,为刚入坑的小白,对于一些常见的 Unity 2D 开发问题给出解决方案(啊,尤其是我) 一些方案可能并非最优解,但 ...

  4. Unity 2D 游戏开发 官方视频学习顺序

    unity2D的官方tutorial上已经有了不少的教程视频,都看一遍的话最起码也知道unity能干什么了. 自学这段时间里我翻译不少,之前也发过,都放到b站了,基本都是1080p的,而且压制后文件不 ...

  5. Unity 2D游戏开发教程之摄像头追踪功能

    Unity 2D游戏开发教程之摄像头追踪功能 上一章,我们创建了一个简单的2D游戏.此游戏中的精灵有3个状态:idle.left和right.这看起来确实很酷!但是仅有的3个状态却限制了精灵的能力,以 ...

  6. Unity 2D游戏开发教程之游戏中精灵的跳跃状态

    Unity 2D游戏开发教程之游戏中精灵的跳跃状态 精灵的跳跃状态 为了让游戏中的精灵有更大的活动范围,上一节为游戏场景添加了多个地面,于是精灵可以从高的地面移动到低的地面处,如图2-14所示.但是却 ...

  7. Unity 2D游戏开发教程之为游戏场景添加多个地面

    Unity 2D游戏开发教程之为游戏场景添加多个地面 为游戏场景添加多个地面 显然,只有一个地面的游戏场景太小了,根本不够精灵四处活动的.那么,本节就来介绍一种简单的方法,可以为游戏场景添加多个地面. ...

  8. Unity 2D游戏开发教程之精灵的死亡和重生

    Unity 2D游戏开发教程之精灵的死亡和重生 精灵的死亡和重生 目前为止,游戏项目里的精灵只有Idle和Walking这两种状态.也就是说,无论精灵在游戏里做什么,它都不会进入其它的状态,如死亡.于 ...

  9. ​Unity 2D游戏开发教程之2D游戏的运行效果

    ​Unity 2D游戏开发教程之2D游戏的运行效果 2D游戏的运行效果 本章前前后后使用了很多节的篇幅,到底实现了怎样的一个游戏运行效果呢?或者说,游戏中的精灵会不会如我们所想的那样运行呢?关于这些疑 ...

最新文章

  1. 支付宝二面:Mybatis接口Mapper内的方法为啥不能重载吗?我直接懵逼了...
  2. 通过正则表达式验证日期
  3. 前端月趋势榜:4 月最热门的 20 个前端开源项目 - 2104
  4. Qt (5.10.0)for android
  5. html在状态栏中显示时间,html网页时间显示代码和倒计时代码大全
  6. loop和[bx]的联合应用
  7. VBA——Msgbox
  8. 步进电机速度不够怎么办?
  9. one-stage 目标检测——M2Det源码运行测试
  10. 矿井下无线基站和地面服务器,煤矿井下无线通信系统_矿井通信
  11. java中的汇编指令_查看Java的汇编指令
  12. python幂次_python n次幂
  13. Java:爬取代理ip,并使用代理IP刷uv
  14. 我认为还算经典的语录
  15. uniapp使用阿里云OSS直接上传文件
  16. 用友t3服务器地址在哪里修改,畅捷通T+pos端后续想更换服务器地址链接,怎么操作?...
  17. PhotoShop中的自由变换UI实现
  18. 电影推荐系统(推荐系统的hello work)
  19. Web渗透之信息收集——目录扫描从御剑到Dirbuster
  20. 道路积水监测系统产品选型

热门文章

  1. 变频器制动电阻的选择(如G120变频器报警F7901失速报警)
  2. 视频播放库Vitamio的使用以及功能扩展
  3. 水管工游戏——dfs
  4. FATFS FIL 结构
  5. Threejs动态箭头
  6. 短视频直播系统的功能
  7. 关于在JS中引入JS文件的JQ方法
  8. desmos绘制心形图案
  9. 盛世看增长,乱世看效率 from 思维碎片@知识星球
  10. 五款堪称神奇的手机APP 一定不要错过了