Как используя Canvas собрать кликабельную карту мира на Unity3d

в 12:42, , рубрики: canvas, game development, image, sprite, unity, unity3d, unity3d уроки, разработка

Возникла задача собрать карту мира. Причем именно собрать из множества стран, стран-регионов, потому как страны должны быть кликабельны. Да проще некуда, скажете вы, всего-то и надо запилить целую карту да развесить по странам полигон-коллайдеры, пффф… Но нет, подразумевается, что страна должна будет изменять цвет на красный или черный и при клике будет выделяться белым. Кроме того, со временем на стране должны появляться красные поинты (да-да… я знаю, о чем вы подумали). Этих поинтов должно быть достаточно много на карте.

Было принято решение собрать карту при помощью Canvas. Удобная штука, экономит массу времени. Но не в этот раз.

1-я проблема — страна разных размеров и возникает ситуация, когда одна страна прозрачным участком закрывает океан, или другие страны, и клик приходится не туда, куда нужно, точнее, не туда, куда логически хотелось бы (немного обелил спрайты, что бы вы увидели весь ужас происходящего).

image

Первая мысль: проблем-то навесить Button на Image-объект и дело в шляпе, но нет, проблему это не решит, Button основывается на Image и прозрачные участки он не пропускает, то есть прозрачные участки все равно будут кнопками.
Вторая мысль: получить пиксель рисунка в точке нажатия и если пиксель не прозрачный, то мы кликнули туда, куда надо, если прозрачный, то посмотреть, какие еще есть пиксели в точке нажатии.

И вот тут ступор. Как получать пиксель рисунка, это не проблема, тут масса примеров, а вот как получить канвасовские объекты в точке нажатия? Канвас не имеет коллайдера, поэтому пускать Raycast бесполезно, ничего не вернет. А пихать на каждую image-страну полигон-коллайдер дикость.

Что ж, после прочтения справки и просмотров мануал-видео с англоговорящими индусами на Youtube пришел к выводу, что настало время использовать возможности EventSystem.

Создал скрипт для стран CountryMap и наследовал его от интерфейса IPointerClickHandler, который входит в вышеуказанный нэймспэйс. Единственный метод этого интерфейса OnPointerClick принимает на вход переменную типа PointerEventData. Из этой переменной можно получить много интересной информации, но мне нужна только позиция нажатия.

Окей, страна кликабельна (благодаря интерфейсу), позицию тапа мы знаем, осталось достать пиксель картинки под этой позицией. Пишем небольшой метод:

private bool IsAlphaPoint(PointerEventData eventData)
    {
        Vector2 localCursor;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localCursor);
        Rect r = RectTransformUtility.PixelAdjustRect(GetComponent<RectTransform>(), GetComponent<Canvas>());
        Vector2 ll = new Vector2(localCursor.x - r.x, localCursor.y - r.y);

        int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height);
        int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
        if (CountryImg.sprite.texture.GetPixel(x, y).a > 0) return false;
        else return true;
    }
public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
           
        }
}

Если будет интересно, опишу подробнее. Если вкратце, то преобразовываем позицию из координат экрана в локальные координаты картинки, получаем позицию относительно центра картинки, вычисляем координаты пикселя картинки, достаем пиксель, проверяем на альфа-канал.

Все, огонь, запускаем!

image

Стоит запрет на чтение текстуры, находим спрайт картинки и выставляем ему следующие параметры:

image

Теперь все нормально, однако…

2-я проблема. Пиксель мы нашли, альфа-канал определили. Но под прозрачным слоем все равно находится другая страна.

Опять же на помощь приходит EventSystem, у которого есть свой Raycast с блэкджеком и gameObjecta’ми.

 List<RaycastResult> raycastResults=new List<RaycastResult>();
  EventSystem.current.RaycastAll(eventData, raycastResults);

Список объектов получили, теперь можно с этим работать:

 public void MayBeYouWantClickMe(List<CountryMap> ResultsCountryMap, PointerEventData eventData)
    {
        if (!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            ResultsCountryMap.Remove(this);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);
        }
    }
    public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            List<RaycastResult> raycastResults=new List<RaycastResult>();
            EventSystem.current.RaycastAll(eventData, raycastResults);
            List<CountryMap> ResultsCountryMap = raycastResults.Select(x => x.gameObject.GetComponent<CountryMap>()).ToList();
            ResultsCountryMap.RemoveAll(x => x == null || x.gameObject==gameObject);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);

        }

Ну, вот и все, теперь даже если над непрозрачным рисунком будет даже 100 и более прозрачных, мы все равно увидим непрозрачный.

Приведу полный код скрипта:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Linq;
public class CountryMap : MonoBehaviour,IPointerClickHandler {
    Image CountryImg;
    Image SelectCountry;
    public event CountryMapEvent TapEvent;

    void Awake()
    {
        CountryImg = GetComponent<Image>();
        SelectCountry = transform.GetChild(0).GetComponent<Image>();
        SelectCountry.sprite = Resources.Load<Sprite>("Image/Countries/" + CountryImg.sprite.name);
    }
    private bool IsAlphaPoint(PointerEventData eventData)
    {
        Vector2 localCursor;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(GetComponent<RectTransform>(), eventData.position, eventData.pressEventCamera, out localCursor);
        Rect r = RectTransformUtility.PixelAdjustRect(GetComponent<RectTransform>(), GetComponent<Canvas>());
        Vector2 ll = new Vector2(localCursor.x - r.x, localCursor.y - r.y);

        int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height);
        int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
        if (CountryImg.sprite.texture.GetPixel(x, y).a > 0) return false;
        else return true;
    }
    public void MayBeYouWantClickMe(List<CountryMap> ResultsCountryMap, PointerEventData eventData)
    {
        if (!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            ResultsCountryMap.Remove(this);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);
        }
    }
    public void OnPointerClick(PointerEventData eventData)
    {
        
        if(!IsAlphaPoint(eventData))
        {
            print(gameObject.name);
            if (TapEvent != null) TapEvent(this);
        }
        else
        {
            List<RaycastResult> raycastResults=new List<RaycastResult>();
            EventSystem.current.RaycastAll(eventData, raycastResults);
            List<CountryMap> ResultsCountryMap = raycastResults.Select(x => x.gameObject.GetComponent<CountryMap>()).ToList();
            ResultsCountryMap.RemoveAll(x => x == null || x.gameObject==gameObject);
            if (ResultsCountryMap.Count > 0) ResultsCountryMap[0].MayBeYouWantClickMe(ResultsCountryMap, eventData);

        }
    }

    public void StopSelect()
    {
        StopAllCoroutines();
        SelectCountry.color = new Color32(255, 255, 255, 0);
    }
    public void StartSelect()
    {
        StartCoroutine(Selecting());
    }
    IEnumerator Selecting()
    {
        int alpha=0;
        int count = 0;
        while (true)
        {
            alpha = (int)Mathf.PingPong(count, 150);
            count = count > 300 ? 0 : count + 5;
            SelectCountry.color = new Color32(255, 255, 255, (byte)alpha);
            yield return new WaitForFixedUpdate();
        }
    }
}

А теперь бонус от решения задачки с помощью просмотра пикселя. Помните ту картинку, где мы ставили параметры спрайту? Так вот, есть там такая галочка Read/Write Enabled, именно благодаря ей мы можем получить доступ к пискселю. Как понятно из слова Write — не только для чтения.

Мы можем менять пиксели как нам угодно!

Пример, осветление спрайта:

Texture2D tex = CountryImg.sprite.texture;
        Texture2D newTex = (Texture2D)GameObject.Instantiate(tex);
        newTex.SetPixels32(tex.GetPixels32());
        for (int i = 0; i < newTex.width; i++)
        {
            for (int j = 0; j < newTex.height; j++)
            {
                if (newTex.GetPixel(i, j).a != 0f) newTex.SetPixel(i, j, newTex.GetPixel(i, j)*1.5f);
                
            }
        }
        
        newTex.Apply();
        CountryImg.sprite = Sprite.Create(newTex, CountryImg.sprite.rect, new Vector2(0.5f, 0.5f));

Было:

image

Результат:

image

На этом все. Очень надеюсь, что эта статья хоть как-то кому-то поможет. Если есть вопросы или замечания, пожалуйста, пишите комментарии.

Автор: Djonny_D

Источник

Поделиться новостью

* - обязательные к заполнению поля