Как используя Canvas собрать кликабельную карту мира на 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

На этом все. Очень надеюсь, что эта статья хоть как-то кому-то поможет. Если есть вопросы или замечания, пожалуйста, пишите комментарии.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 26

    +1
    Делали аналогичное для хидден-игры, надо было чтобы спрайт по которому кликнули распадался на пикселы и превращался в партикл систему.
    А не поломается вся схема если спрайты в атласы сложить, uv координаты ведь придется считать относительно позиции спрайта в атласе?
    Второе, просто уточнение, мало ли кто не знает, что isReadable текстура в памяти занимает в два раза больше места.
    Третье, осветление записью в текстуру — это наверно не очень вариант, приходится оперировать большими данными в памяти, проще решить это шейдером и/или цветом спрайтов. Но как информация — несомненно полезно.
      0
      На счет игры, было бы очень интересно посмотреть, как вы это реализовали)
      Про атласы вопрос заставил врасплох, раньше не приходилось с ними работать( к сожалению), но на скорую руку собрал атлас из стран. И да, вы правы, схема ломается. Данный метод работает только, если текстурки отделены друг от друга.
      С шейдерами тоже пока углубленно не сталкивался, слишком зелен для этого.
        0
        Делал это в игре Dreamwoods 2, на ютубе можно найти трейлеры, там будет этот эффект (правда художник все упростил и заменил разноцветные частицы одним цветом). Делается просто. Готовится партикл система с нужными настройками физики. Затем с определённым шагом читаем пикселя со спрайта и по соответствующим координатам эмитируем партикл цвета этого пикселя, и запускаемых систему. Мне там ещё приходилось заставлять партикли лететь и складываться в картинку в другом месте.
        0
        В общем, на счет атласов, провел я не большое исследование, и сразу хочу сказать огромное спасибо, что направили в эту сторону! Уменьшились DrawCalls, да и памяти текстуры стали меньше кушать. Раньше я подозревал, что атласы использовать мудро, но слабо представлял почему.
        На счет работы схемы, нужно дописать пару строк, что бы все работало так же прекрасно, а именно:

            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);
                //Найдем пиксель где начинается спрайт нужной страны в атласе - локальный (0,0)
                Vector2 pixelStart=new Vector2(CountryImg.sprite.texture.width*CountryImg.sprite.uv[2].x, 
                                                CountryImg.sprite.texture.height*CountryImg.sprite.uv[2].y);
                int x = (int)(ll.x / r.height * CountryImg.sprite.textureRect.height );
                int y = (int)(ll.y / r.height * CountryImg.sprite.textureRect.height);
                //вычесляем координаты относительно атласа
                pixelStart+=new Vector2(x,y);
                print("itog:" + pixelStart.x + " " + pixelStart.y);
                return IsAlphaPoint((int)pixelStart.x, (int)pixelStart.y);
            }
        


        Правда есть один нюанс касательно чтения пикселей из атласа. Если формат атласа используется Compressed(как я понял все разновидности) то будет возникать такие исключения:

        x == 0 && y == 0 && blockWidth == dataWidth && blockHeight == dataHeight
        UnityEngine.Texture2D:GetPixel(Int32, Int32)


        Следствием чего работать не будет (ну еще бы оно с ошибками работало…).
        Это очень печальное ограничение, текстура с форматом ARGB32 например будет занимать 16 Мб…а в памяти и того больше.
        Но благо есть ARGB16… в общем поджал атлас до 2 Мб. Смотрится хорошо, все работает, как надо.
        0
        А я использовал политическую карту мира. Рисовал обычную, а страну определял по цвету пикселя соотвествующего на политической карте мира. Выбрать можно было любую страну с точностью до пиксела. Довольно удобно и чертовски просто.
          0
          Да, это упростило бы задачу, если бы не концепция дизайна игры.
            0
            Для вашего случая можно было бы сделать подобно — обычная карта (одно цельное изображение с нормальными цветами), отображаемая на экране, и цветная (также одно цельное изображение), с которой будет происходить внутренняя работа. Проверка страны по координатам — посмотреть смещение относительно левого верхнего угла карты на экране и взять цвет пикселя с таким же смещением в раскрашенной карте. Таким образом узнали, что за страна. Для подсвечивания определённой страны, можно использовать фрагментный шейдер, в который дополнительно передавать текстуру раскрашенной карты и дополнительные параметры — цвет страны, которую нужно подсвечивать и тип подсвечивания. А шейдер проверяет цвет пикселя в раскрашенной карте и в зависимости от этого, либо рисует просто, либо с подкрашиванием, либо не рисует вообще (так можно сделать вариант, что рисоваться будет только одна указанная страна с необходимым подсвечиванием) — то есть, цветная карта используется просто как маска.
          0
          ещё один нюанс, совсем маленький. Не стоит использовать сравнение флотов на равенство «color.a ==0f». Рискуете получить непредсказуемые результаты на разных машинах, правильнее обращаться к Mathf.Approx (вроде так). Или сравнивать "< 0.01f" или "< Mathf.Epsilon"
            0
            Учту, спасибо!
            0
            Вы меня простите, но все чему вы «учите» в этой статье — категорически неверно и нужно бить по рукам каждый раз, когда кто-то делает нечто подобное.

            1. Texture read/write очень сильно потребляет расход памяти, делает невозможным использование сжатия текстур, да и вообще — работает очень медленно. Для целей определения клика отлично подходит система полигональных коллайдеров.

            2. Осветление текстуры однозначно надо делать шейдером. Это просто в сотни (а то и тысячи) раз быстрее. Менять пиксели в текстуре — это вообще из ряда вон выходящая практика и применяется крайне редко, когда без нее никак (например, когда нужно рисовать мышкой на текстуре и т.п.)

            В результате вы написали тонну лишнего кода (все эти беганья по текстурам и сравнивание пикселей), где можно было обойтись одной строчкой кода «получить объект под мышкой». Да, придется обвести коллайдером спрайт. Но это не так страшно и быстро. Да и если очень хочется, есть автоматические тулзы.
              0
              1. Как я думаю, не обязательно это должно быть верным, если есть Texture read/write значит это зачем то нужно, и это нужно использовать. Пожалуйста, разъясните, почему и как это делает невозможным, сжимать текстуры, я сжал атлас стран до 2 Мб, при этом они имеют презентабельный вид, даже лучше чем раньше, до написания статьи. Да и на производительность, я не заметил, что сказывается. Если имеется ввиду что нельзя применить формат Compressed, то да я с вами согласен, нельзя. Но для моего случая это оказалось не критичным.

              2.Как я уже говорил в сторону шейдеров, возможности посмотреть не было, и да по примерам, это действительно было бы лучше. Но я привел все это как пример, мало ли кому пригодиться.
                0
                Зачем и кому это (read/write) нужно — я написал. Это случай на самый-самый край, который используется только когда никак иначе нельзя. Вы прививаете себе, в главное — всем читающим изначально неверные привычки. Вы увеличили расход памяти в разы (если сравнивать со сжатым атласом, то получится раза в 4-8, в зависимости от формата сжатия), вы понизили производительность на порядок, и все это без какой-либо необходимости делать именно так. Кроме того, вы написали 3 десятка строк там, где можно было обойтись одной. Если вы говорите «я не почувствовал», то это исключительно из-за размера проекта.

                Хотите дальше забавать гвозди микроскопом — ок, я не буду переубеждать.

                Я лишь буду надеяться, что эта статья никому не пригодится и никто не воспримет ее как руководство к действию :)
                  0
                  Спасибо, за ваши замечания. Я понимаю, что у меня получилось не превосходное решение, однако учитывая мой опыт и сжатые сроки, это оптимальное решение.
                  Я же все таки, надеюсь, что статья кому-то, да пригодиться, хотя бы в плане ознакомления.
                0
                Есть GraphicRaycaster, не надо коллайдеров.
                  0
                  Тем более :)
                  Мне просто пока не пришлось глубоко сталкиваться с новым uGUI, я старообрядец.
                0
                Если не секрет, откуда вы взяли сами картинки (карту)?
                  0
                  Работа арт-отдела компании, в которой я работаю.
                  0
                  Можно вообще оттрассировать контуры стран в 2д/меш-колайдеры, а карту держать одной картинкой — максимальная производительность по рендеру, мало кода, неплохая производительность по физике на unity >=5.0.
                    0
                    Прочитал первый абзац по диагонали, пропустил про выделение цветами. Если нужно выделение территории цветом, то к предыдущему комменту можно добавить следующее — раз уже есть мешколайдеры, то при детекте фокуса достаточно брать этот меш и рендерить его с полупрозрачностью поверх существующей карты с подкраской нужным цветом. Блендинг меша одной страны будет все-равно дешевле карты, работающей 100% в транспаренте.
                    0
                    Какой то список вредных советов, не иначе. Первая картинка особенно пугает. Использовать канвас для такой задачи просто странно. Я бы сделал все так:
                    1. Страны — это спрайты, рендерятся СпрайтРендерами без всяких канвасов.
                    2. К СпрайтРендерам добавляется ПолигонКоллайдер2Д и он автоматически принимает форму спрайта, ничего не нужно делать вручную. Если сгенерился не очень аккуратный, можно сразу подредактировать.
                    3. Вешаем на этот спрайт свой скрипт, он имплементирует iPointerClickHandler.
                    4. На камеру вешаем PhysicsRaycaster2D, в сцену добавляем объект EventSystem. После этого скрипт карты начинает принимать сообщения о кликах.
                    5. Если нужно подсветить какую то страну — пишем простенький шейдер и накладываем материал на этот спрайт.

                    В итоге кода то и не будет в программе. Только обработчик клика. Ну и шейдер осветления, если действительно нужен.
                    Read/Write — не просто так вынесли в дополнительные настройки — его не стоит использовать просто так.
                      0
                      1. Канвас использовал, как удобный инструмент для соединения стран в одну карту.
                      2. Редактировать я замучаюсь каждый раз. Мне проще пару строк кода написать.
                      3-4. Судя по вашей логике, чем вам тогда OnMouseDown не угодил?
                      5. Согласен, шейдер бы здесь помог, к сожалению я их еще не познал. Надеюсь, что вы посоветуете некоторые источники по их изучению.
                        0
                        Ответил ниже, случайно
                      0
                      Может я просто описал не очень хорошо. Некоторые вещи проще один раз показать:
                      www.dropbox.com/s/pl7l5i5hsck2yjg/2015-06-22_22-37-36.mp4?dl=0
                      1. Не вижу сложности при соединение стран — спрайтов. Да и особой разницы канвас или спрайт в этом случае тоже.
                      2. Так редактировать то ничего и не нужно. Ну если только сгенеренный коллайдер не устроит. Так это ж наоборот плюс. По некоторым маленьким странам очень солжно попасть. И область клика у них явно нужно делать больше, чем сама страна.
                      3-4. Я просто решил, что вам удобно пользоваться PointerClick. Ну и удобно все таки, тут вам и драг энд дропы и все остальное.
                      5. Самое верное — скачать стандартные шейдеры и изучать их. Удобно, так как достаточно лишь чуть изменить код и у вас уже готовое решение. В частности стандартный шейдер спрайта советую брать за базовый. Ну и литературу тоже полезно читать, но тут уж без конкретики, что найдете. Разницы нет особой.
                        0
                        1. Просто страны, к сожалению, нарисованы немного коряво, приходится подгонять размеры по реальной карте. Для Image не нужно менять Scale, на сколько я знаю если использовать SpriteRenderer и разный scale, то автоматический батчинг работать не будет, поправьте меня, если я не прав.
                        3-4. Вы правы, использование PointerClick дает много возможностей, однако, мне в данной задаче нужно только нажатие.
                        5. Спасибо, буду изучать.
                          0
                          то автоматический батчинг работать не будет

                          Не уверен насчет SpriteRenderer (не использую штатный гуй / 2д), но батчинг для мешей работает на разном скейле (за исключением отрицательного) на unity >=5.0. Ну и да, спрайты должны лежать в одном атласе — батчинг происходит по материалам.
                            0
                            Батчинг работать будет. Canvas и Image созданы для удобства создания интерфейсов и выйгрыша в скорости в большинстве случаев по сравнению со спрайтами не дадут. Ну и странно немного слышать аргументы о производительности после настолько неоптимального использования ресурсов(read/write текстуры, ReadPixels/SetPixels и пр.) и аргументов о том, что это позволительно, так как производительности хватает.

                            А расставлять страны вручную, да еще и со скейлом это вообще неблагодарное дело.

                        Only users with full accounts can post comments. Log in, please.