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);
            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<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);
        }
    }
    #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<Image>();
        iconRange = transform.Find("Range").GetComponent<Image>();
        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;
    }

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

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
### 回答1: Unity2D RPG手游源码是一种可用于制作角色扮演类手游的代码基础。Unity是一款游戏引擎,而2D RPG则代表游戏类型为二维角色扮演。通过使用Unity2D RPG手游源码,开发者可以快速搭建一个具备角色选择、剧情交互、战斗系统、升级系统等基本要素的手游。 该源码通常包含了游戏的各种元素和功能的实现代码,如角色控制、任务系统、道具系统、技能系统等。使用源码可以节省开发时间,同时也便于开发者进行二次开发和定制。 对于想要制作二维角色扮演类手游的开发者来说,Unity2D RPG手游源码是一个很好的起点。通过学习和修改源码,开发者可以根据自己的需求来制作出具备独特特色的游戏。 值得一提的是,源码只是游戏开发的一部分,还需要配合各种素材和资源进行开发开发者需要设计游戏世界的地图、角色、怪物和场景,同时编写数值平衡和游戏逻辑等。所以除了掌握源码,开发者还需要具备一定的美术和设计能力。 总之,Unity2D RPG手游源码是制作二维角色扮演类手游的基础代码。通过学习和修改源码,开发者可以制作出符合自己需求的游戏,并需要配合素材和资源进行完整的开发工作。 ### 回答2: Unity2D RPG 手游源码是用于开发二维角色扮演游戏的解决方案。该源码提供了一系列游戏开发所需的基本功能和特性,以便开发者能够快速搭建和定制自己的RPG游戏。 在Unity2D RPG 手游源码中,通常会包括以下主要功能: 1. 角色控制:该源码提供了用于角色移动、跳跃、攻击和技能释放等动作控制的功能。开发者可以根据游戏需求进行自定义调整。 2. 物品系统:该源码通常包含物品的管理系统,可以实现物品的获取、使用和存储。开发者可以轻松添加各种装备、道具和消耗品。 3. NPC 交互:该源码提供了与非玩家角色(NPC)进行对话、任务接取和完成等交互功能。开发者可以添加自己的任务和对话系统。 4. 角色属性:该源码通常包括角色的属性系统,例如生命值、魔法值、攻击力和防御力等。开发者可以调整属性数值来平衡游戏玩法。 5. 场景管理:该源码提供了场景之间的切换和加载功能,使得开发者可以构建出一个连贯的游戏世界。 6. 性能优化:该源码通常会包含一些优化技巧,以确保游戏在不同设备上有平稳的运行体验。 总体来说,Unity2D RPG 手游源码是一个强大且灵活的解决方案,可以帮助开发者更轻松地构建自己的角色扮演游戏。通过使用该源码,开发者可以加速开发进程,同时还可以根据自身需求对游戏系统进行自定义和扩展。 ### 回答3: Unity2D RPG 手游源码是一套用于制作角色扮演类手游的代码资源。Unity是一款流行的游戏开发引擎,2D表示游戏是以平面的方式进行展示,RPG是角色扮演游戏的缩写。 这套源码提供了一些基础的游戏功能,比如角色的移动、攻击、升级、装备等。通过使用这些源码,开发者可以快速搭建起一个简单的RPG手游框架,并在此基础上添加更多自定义的功能和内容。 源码中通常包含了角色的属性和能力系统,游戏地图的编辑器,角色控制脚本,敌人AI脚本等。开发者可以在此基础上进行扩展和修改,以适应自己的游戏需求。 使用Unity2D RPG 手游源码可以大大节省游戏开发的时间和精力,因为它提供了一个已经实现了一些常见功能的蓝图。同时,它还可以作为学习游戏开发的工具,通过研究源码中的实现方式和逻辑,开发者能够更好地理解和掌握游戏开发的技术。 然而,使用源码也需要具备一定的编程知识和经验。开发者需要熟悉Unity引擎以及C#编程语言,才能使用源码进行修改和定制。 总之,Unity2D RPG 手游源码是一种快速开发角色扮演类手游的解决方案,它提供了基础功能和框架,可以帮助开发者节省时间和精力。但在使用之前需要具备一定的编程知识和经验。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

莫需要

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值