一直觉着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();
if (!iconButton) iconButton = GetComponent
很少吧?我刚刚在上面说了,图标点击是可选功能,所以图标可以没有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
{
[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 iconsWithHolder = new Dictionary();
private readonly List iconsWithoutHolder = new List();
#region 地图图标相关
public MapIcon CreateMapIcon(MapIconHolder holder)
{
if (!UI || !UI.gameObject) return null;
MapIcon icon = ObjectPool.Instance.Get(UI.iconPrefb.gameObject, UI.iconsParent).GetComponent();
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();
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);
else
playerIconInsatance.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 MonoBehaviour
private void Start()
{
ToMiniMap();
playerIconInsatance = ObjectPool.Instance.Get(UI.iconPrefb.gameObject, UI.iconsParent).GetComponent();
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);
}
}
#endregion
private 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_STANDALONE
if (eventData.clickCount > 1)
MapManager.Instance.CreateMarkByMousePosition(eventData.position);
#elif UNITY_ANDROID
if (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
#endif
canZoom = true;
}
public void OnPointerExit(PointerEventData eventData)
{
#if UNITY_STANDALONE
#endif
canZoom = false;
}
private void Update()
{
if (canZoom) MapManager.Instance.ZoomMap(Input.mouseScrollDelta.y);
}
#if UNITY_ANDROID
private 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_ANDROID
if (eventData.button == PointerEventData.InputButton.Left)
{
if (pressCoroutine != null) StopCoroutine(pressCoroutine);
pressCoroutine = StartCoroutine(Press());
}
#endif
}
public void OnPointerUp(PointerEventData eventData)
{
#if UNITY_ANDROID
if (pressCoroutine != null) StopCoroutine(pressCoroutine);
#endif
}
public void OnPointerEnter(PointerEventData eventData)
{
onEnter?.Invoke();
}
public void OnPointerExit(PointerEventData eventData)
{
#if UNITY_ANDROID
if (pressCoroutine != null) StopCoroutine(pressCoroutine);
#endif
}
private void Awake()
{
iconImage = transform.Find("Icon").GetComponent();
iconRange = transform.Find("Range").GetComponent();
if (iconRange) iconRange.raycastTarget = false;
}
#if UNITY_ANDROID
readonly 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 ┗━┛ 3
Vector3[] 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;
}
阔别三个月,再次敲起喜欢的代码,实乃心旷神怡。