Unity 2D独立开发手记(九):UGUI仿GTA地图系统
一直觉着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地图系统相关推荐
- Unity 2D独立开发手记(五):通用任务系统
一年多没写博了,因为迫不得已转行,这破游戏也搁置了好久,过完年也有一个月了,回来找找感觉.那就记录一下任务系统的开发吧,方便以后回忆.(2022年3月注:文章中的任务系统太旧了,仅供思路参考,获取新版 ...
- Unity 2D游戏开发快速入门第1章创建一个简单的2D游戏
Unity 2D游戏开发快速入门第1章创建一个简单的2D游戏 即使是现在,很多初学游戏开发的同学,在谈到Unity的时候,依然会认为Unity只能用于制作3D游戏的.实际上,Unity在2013年发布 ...
- Unity 2D 游戏开发解决方案大全
Unity 2D 游戏开发解决方案大全 一些官方腔 这篇文章会是一个大纲模式,致力于,为刚入坑的小白,对于一些常见的 Unity 2D 开发问题给出解决方案(啊,尤其是我) 一些方案可能并非最优解,但 ...
- Unity 2D 游戏开发 官方视频学习顺序
unity2D的官方tutorial上已经有了不少的教程视频,都看一遍的话最起码也知道unity能干什么了. 自学这段时间里我翻译不少,之前也发过,都放到b站了,基本都是1080p的,而且压制后文件不 ...
- Unity 2D游戏开发教程之摄像头追踪功能
Unity 2D游戏开发教程之摄像头追踪功能 上一章,我们创建了一个简单的2D游戏.此游戏中的精灵有3个状态:idle.left和right.这看起来确实很酷!但是仅有的3个状态却限制了精灵的能力,以 ...
- Unity 2D游戏开发教程之游戏中精灵的跳跃状态
Unity 2D游戏开发教程之游戏中精灵的跳跃状态 精灵的跳跃状态 为了让游戏中的精灵有更大的活动范围,上一节为游戏场景添加了多个地面,于是精灵可以从高的地面移动到低的地面处,如图2-14所示.但是却 ...
- Unity 2D游戏开发教程之为游戏场景添加多个地面
Unity 2D游戏开发教程之为游戏场景添加多个地面 为游戏场景添加多个地面 显然,只有一个地面的游戏场景太小了,根本不够精灵四处活动的.那么,本节就来介绍一种简单的方法,可以为游戏场景添加多个地面. ...
- Unity 2D游戏开发教程之精灵的死亡和重生
Unity 2D游戏开发教程之精灵的死亡和重生 精灵的死亡和重生 目前为止,游戏项目里的精灵只有Idle和Walking这两种状态.也就是说,无论精灵在游戏里做什么,它都不会进入其它的状态,如死亡.于 ...
- Unity 2D游戏开发教程之2D游戏的运行效果
Unity 2D游戏开发教程之2D游戏的运行效果 2D游戏的运行效果 本章前前后后使用了很多节的篇幅,到底实现了怎样的一个游戏运行效果呢?或者说,游戏中的精灵会不会如我们所想的那样运行呢?关于这些疑 ...
最新文章
- 支付宝二面:Mybatis接口Mapper内的方法为啥不能重载吗?我直接懵逼了...
- 通过正则表达式验证日期
- 前端月趋势榜:4 月最热门的 20 个前端开源项目 - 2104
- Qt (5.10.0)for android
- html在状态栏中显示时间,html网页时间显示代码和倒计时代码大全
- loop和[bx]的联合应用
- VBA——Msgbox
- 步进电机速度不够怎么办?
- one-stage 目标检测——M2Det源码运行测试
- 矿井下无线基站和地面服务器,煤矿井下无线通信系统_矿井通信
- java中的汇编指令_查看Java的汇编指令
- python幂次_python n次幂
- Java:爬取代理ip,并使用代理IP刷uv
- 我认为还算经典的语录
- uniapp使用阿里云OSS直接上传文件
- 用友t3服务器地址在哪里修改,畅捷通T+pos端后续想更换服务器地址链接,怎么操作?...
- PhotoShop中的自由变换UI实现
- 电影推荐系统(推荐系统的hello work)
- Web渗透之信息收集——目录扫描从御剑到Dirbuster
- 道路积水监测系统产品选型