VRChat 에서 뉴스 보기 (RSS+github.io)와 이어집니다

들어가며

전 포스팅에서는 RSS를 받아와 github.io에 자동으로 커밋하게끔 구축해두었다.

동아일보

매일경제

조선일보

HTML은 다음과 같다

<!--body위는 생략-->
<body>
테마0_테마1_테마2_테마3_
테마0_기사0
기사1
기사2
기사3
....
^테마1_기사0
기사1
기사2
....

Unity에서 필요한 기능은

  • 언론사, 테마에 따라 변경되는 UI
  • 한 면에 표시되는 기사의 수를 조절

를 목표로 했다.

Unity UI

버튼과 스크롤 뷰를 활용

우선적으로 생각해야 할 것은, 언론사의 수, 테마의 수만큼 버튼을 생성/삭제 하는것이다. 인스턴스화와 디스트로이로 생성/삭제가 가능하지만, 조금 귀찮더라도 셋 액티브로 해결하기로 했다.

Unity UI의 든든 국밥같은 친구, 스크롤뷰의 LayoutGroup과 ContentSizeFilter를 이용했다.

LayoutGroup은 자식 오브젝트를 자동으로 정렬해주는 기능이다. 수직 또는 수평으로 자식 오브젝트를 나열하고, 간격을 설정할 수 있다.

ContentSizeFilter는 UI 요소의 크기를 자동으로 조절하는 기능이다. 이 컴포넌트를 사용하면 부모 오브젝트의 크기에 맞게 UI 요소의 크기를 조절할 수 있다. 예를 들면, 부모 오브젝트의 크기가 변경되면 자식 UI 요소의 크기도 자동으로 조절된다.

버튼은 자동으로 왼쪽부터 채워지니, 모두 끈 후 필요한 수만큼 키면 되는것이다.

스크롤뷰의 콘텐츠에 버튼을 넣으면 레이캐스트가 겹쳐, 버튼의 클릭이 되지 않고 스크롤뷰로 가는 버그가 있는데, Event Trigger컴포넌트를 넣어 우선 순위를 올려주면 된다.

그 밖에는… 음..

VRChat한정으로 버튼과 상호작용을 하려면 레이아웃을 디폴트로, 캔버스에 VRC UI Shape 컴포넌트를 넣어줘야 한다.

그 밖에는 WASD키를 누르면 UI가 움직이니 Navigation을 꺼야 한다 정도?

필요한 것만 넣어둔 컨트롤 패널

컨트롤 패널만 있으면 심심하니 VR로 들고 다닐 수 있는 미니패널 하나

사실 구현보다 배경 텍스쳐 고르는게 더 힘들었다 ㅋㅋ

이과의 감성이란…

어짜피 뉴스 기능이 필요한 사람은 받은 다음에 꾸며서 쓰지 않을까….

기능에 집중하자

HTML 파싱 코드

파싱 라이브러리를 사용할 수 없어

이젠 HTML을 불러와서 기사를 잘라야 한다

생각해야 할 것은

  • 기능

    • 사용자가 HTML로드중일 때 조작을 못하게 예외처리
    • 사용자가 한 페이지의 기사 수를 조절 할 수 있게 만들기
  • 최적화

    • VR유저가 많은 많큼, 많은 양의 String을 처리하기에 가비지가 쌓이기 쉽다. Substring을 최소화하고 최대한 인덱스로 접근하자.
    • 한 페이지를 표시할때도 최대한 인덱스로 접근하자
    • 미리 뉴스를 가져오기보다는 필요할 때만 가져오게 하자
    • 중복되는 기능을 묶고 메소드로 만들자
using System;
using System.Linq;
using System.Text;
using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDK3.StringLoading;
using VRC.SDKBase;
using VRC.Udon;

public class KoreaNews : UdonSharpBehaviour
{
    private VRCUrl[] pressUrls = new VRCUrl[]
    {
        new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EB%8F%99%EC%95%84%EC%9D%BC%EB%B3%B4.html"),
        new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EB%A7%A4%EC%9D%BC%EA%B2%BD%EC%A0%9C.html"),
        new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EC%A1%B0%EC%84%A0%EC%9D%BC%EB%B3%B4.html")
    };

    private string[] pressNames = new string[]
    {
        "동아일보",
        "매일경제",
        "조선일보"
    };
    [SerializeField] private Transform pressButtonParent;
    [SerializeField] private Transform themaButtonParent;
    [SerializeField] private Text newsText;
    [SerializeField] private Text PickUpText;
    [SerializeField] private int pageSize = 4;
    [SerializeField] private Text pageText;
    [SerializeField] private Text StatusText;

    private string _news;
    private string _articles;
    private string[] _thema;
    private int _currentPage = 1;
    private int _currentThema = 0;
    private int _maxPage;
    private int[] _themaIndexes;
    private int[] _pageIndexes;
    bool IsReady = false;

    private void Start()
    {
        Debug.Log("pressNameLength" + pressNames.Length);
        CreateButtons(pressButtonParent, pressNames.Length, pressNames);
        themaButtonParent.gameObject.SetActive(false);
        SetStatusText("IsReady");
    }

    private void SetStatusText(string __text)
    {
        StatusText.text = __text;
    }

    public override void OnStringLoadSuccess(IVRCStringDownload result)
    {
        _news = result.Result.Substring(49, result.Result.Length - 16 - 49);
        int firstNewlineIndex = _news.IndexOf('\n');
        _thema = _news.Substring(0, firstNewlineIndex).TrimEnd('_').Split('_');
        _news = _news.Remove(0, firstNewlineIndex + 1);
        CreateButtons(themaButtonParent, _thema.Length, _thema);
        SetThemaIndexes();
        SetStatusText("Last Update: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
        themaButtonParent.gameObject.SetActive(true);
    }

    private void CreateButtons(Transform parent, int buttonCount, string[] buttonTexts)
    {
        Debug.Log("CreateButtons: " + buttonCount + " called");

        Text[] textComponents = parent.GetComponentsInChildren<Text>();

        for (int i = 0; i < textComponents.Length; i++)
        {
            if (i < buttonCount)
            {
                textComponents[i].transform.parent.gameObject.SetActive(true);
                textComponents[i].text = buttonTexts[i];
            }
            else
            {
                textComponents[i].transform.parent.gameObject.SetActive(false);
            }
        }
    }

    private void ShowPage(int page, int themeIndex)
    {

        pageText.text = page + "/" + _maxPage;
        if (page == 1)
        {
            newsText.text = _articles.Substring(0, _pageIndexes[1]);
            PickUpText.text = newsText.text;
        }
        else if (page == _maxPage)
        {
            Debug.Log("LastPage");
            newsText.text = _articles.Substring(_pageIndexes[_pageIndexes.Length-1], _articles.Length - _pageIndexes[_pageIndexes.Length - 1]);
            PickUpText.text = newsText.text;
        }
        else
        {
            newsText.text = _articles.Substring(_pageIndexes[page - 1], _pageIndexes[page] - _pageIndexes[page - 1]);
            PickUpText.text = newsText.text;
        }
    }

    private string GetArticlesForTheme(int themeIndex)
    {
        if (themeIndex == 0)
        {
            return _news.Substring(0, _themaIndexes[0]);
        }

        if (themeIndex == _thema.Length - 1)
        {
            return _news.Substring(_themaIndexes[themeIndex - 1], _news.Length - _themaIndexes[themeIndex - 1]);
        }

        return _news.Substring(_themaIndexes[themeIndex - 1],
            _themaIndexes[themeIndex] - _themaIndexes[themeIndex - 1]);
    }
    
    public void NextPage()
    {
        if(IsReady== false)
            return;
        Debug.Log("NextPage");
        if (_currentPage < _maxPage)
        {
            _currentPage++;
            ShowPage(_currentPage, _currentThema);
        }
    }

    public void PrePage()
    {
        if(IsReady== false)
            return;
        Debug.Log("PrePage");
        if (_currentPage > 1)
        {
            _currentPage--;
            ShowPage(_currentPage, _currentThema);
        }
    }
private void SetPageIndexes(int themeIndex)
    {
        _articles = GetArticlesForTheme(themeIndex);
        int count = 0;
        for (int i = 0; i < _articles.Length; i++)
        {
            if (_articles[i] == '_')
            {
                Debug.Log(_articles);
                count++;
            }
        }
        _maxPage = count / pageSize;

        if (count < pageSize*2)
        {
            _maxPage = 2;
        }
        _pageIndexes = new int[_maxPage];

        int lastIndex = 0;
        int temp = 0;
        
        
        for (int i = 0; i < count; i++)
        {
            if(_pageIndexes.Length == temp)
            {
                Debug.Log(temp);
                for(int j=0;j<_pageIndexes.Length;j++)
                {
                    Debug.Log("PageIndexes: " + j + " " + _pageIndexes[j]);
                }
                break;
            }
            int currentIndex = _articles.IndexOf('_', lastIndex);
            lastIndex = currentIndex + 1;

            if (i % pageSize == 0)
            {
                _pageIndexes[temp] = currentIndex;
                temp++;
            }
        }
        IsReady= true;
    }

    private void SetThemaIndexes()
    {
        int themeCount = _thema.Length;
        _themaIndexes = new int[themeCount - 1];

        int lastIndex = 0;

        for (int i = 0; i < themeCount - 1; i++)
        {
            int currentIndex = _news.IndexOf('^', lastIndex);
            _themaIndexes[i] = currentIndex;
            lastIndex = currentIndex + 1;
        }
    }

    public void ThemaButtonClicked(int themaIndex)
    {
        Debug.Log("ThemaButtonCkick : " + themaIndex + "");
        _currentThema = themaIndex;
        SetPageIndexes(themaIndex);
        _currentPage = 1;
        ShowPage(1, themaIndex);
    }

    public void Press(int pressIndex)
    {
        Debug.Log("PressButton : " + pressIndex + "");

        VRC.SDK3.StringLoading.VRCStringDownloader.LoadUrl(pressUrls[pressIndex], GetComponent<UdonBehaviour>());
        themaButtonParent.gameObject.SetActive(false);
        SetStatusText("Loading...");
    }

    public void Thema0()
    {
        ThemaButtonClicked(0);
    }

    public void Thema1() => ThemaButtonClicked(1);
    public void Thema2() => ThemaButtonClicked(2);
    public void Thema3() => ThemaButtonClicked(3);
    public void Thema4() => ThemaButtonClicked(4);
    public void Thema5() => ThemaButtonClicked(5);
    public void Thema6() => ThemaButtonClicked(6);
    public void Thema7() => ThemaButtonClicked(7);
    public void Thema8() => ThemaButtonClicked(8);
    public void Thema9() => ThemaButtonClicked(9);
    public void Thema10() => ThemaButtonClicked(10);
    public void Thema11() => ThemaButtonClicked(11);
    public void Thema12() => ThemaButtonClicked(12);
    public void Thema13() => ThemaButtonClicked(13);
    public void Thema14() => ThemaButtonClicked(14);
    public void Thema15() => ThemaButtonClicked(15);
    public void Thema16() => ThemaButtonClicked(16);
    public void Thema17() => ThemaButtonClicked(17);
    public void Thema18() => ThemaButtonClicked(18);
    public void Thema19() => ThemaButtonClicked(19);
    public void Press0() => Press(0);
    public void Press1() => Press(1);
    public void Press2() => Press(2);
    public void Press3() => Press(3);
    public void Press4() => Press(4);
    public void Press5() => Press(5);
    public void Press6() => Press(6);
    public void Press7() => Press(7);
    public void Press8() => Press(8);
    public void Press9() => Press(9);
    public void Press10() => Press(10);
    public void Press11() => Press(11);
    public void Press12() => Press(12);
    public void Press13() => Press(13);
    public void Press14() => Press(14);
    public void Press15() => Press(15);
    public void Press16() => Press(16);
    public void Press17() => Press(17);
    public void Press18() => Press(18);

    public void Press19()
    {
        Press(19);
    }
}

아래는 메인인 KoreaNews코드이다.

우선 Start에서 언론사 수만큼 버튼을 켠다.

언론사 버튼이 눌려지면 맨 아래 public void Press(int pressIndex)에서 언론사의 HTML을 가져온다.

가져온 HTML은 OnStringLoadSuccess되어 Html태그를 제거하고 테마를 구분해 테마의 수와 종류를 버튼에 띄운다.

테마 버튼을 선택하면 ThemaButtonClicked로 가서 SetPageIndexes를 돌리고 ShowPage한다.

PageIndexes는 한 테마에서 각 페이지마다 보여줄 범위를 가진다.

SetPageIndexes에서는 _(언더바)마다 나눠진 기사를 pagesize만큼 나눈 후, 각 페이지에서 어디까지 표시할지 인덱스를 저장한다.

사실 코드만 길 뿐이지 들어가는 로직은 없다.

생각치도 못한 숫자세기 문제와(마지막 인덱스가 넘치는 경우) 앞으로 멀티 플레이어를 위한 싱크를 제외하고는 코드만 길 뿐이다.

맨 아래의 Thema(), Press()는 테마, 언론사의 버튼이다.

버튼은 유동적으로 수가 바뀌므로 일단 최대값 20으로 맞춰놓았다.

아래는 버튼의 코드이다.

    public void ButtonClicked()
    {
        transform.parent.parent.parent.parent.GetComponent<UdonBehaviour>()
            .SendCustomEvent(name.Substring(0,5)+transform.GetSiblingIndex());
        
    }

버튼의 상위 UdonBehavior를 찾은 뒤 자신의 이름 앞 5글자와 본인의 하이라키 순서를 이벤트 이름으로 보낸다.

transform.parent.parent.parent.parent 는 추하긴 하지만… GetComponentInParent보다는 낫지 않을까? 싶다.

아. 몰랐었는데 GetComponentInParent는 본인부터 찾기 때문에 transform.parent를 하고 GetComponentInParent을 사용해야 한다.

나중에 작동이 안된다면 필시 여기 부분 일 것이다.

시연&마무리하며

잘 된다. 야호

아래는 간단한 시연

https://youtu.be/Ku52Hjhq1mA

글자의 크기가 가독성이 떨어지긴 하지만… 그건 이 뉴스 패널을 사용할 사람의 몫 아닐…까? ㅋㅋ

앞으로 시간이 난다면 해야 할 일은

SubString(최적화): SubString은 가비지를 생성하므로 StringBuilder를 사용해야 한다. 그러나 VRChat에서는 StringBuilder가 사용불가능하다. 이를 해결하기 위해서는 Remove를 사용하거나 인덱스를 이용해 각 기사에 접근하는 방법을 이용해야 한다.

Sync: 다른 플레이어와의 Sync를 위해서는 현재 플레이어가 보고 있는 언론사,테마,페이지를 가져와야 한다.

HTML을 가져온 후 5초간 로드가 불가능하므로, 만약 다른 플레이어가 로드를 마친 후 페이지조작을 하면 String에서 오류가 날 수 있다.

이를 위한 예외처리를 해야 한다. VRChat에서 try catch가 불가능한것은 덤…

가볍게 시작한 일인데 하루가 후딱 지나가버렸다.

최근 졸업작품만 하다가 내가 하고싶은 것을 하니 조금 더 편안하긴 하다.

다음에 시간만 허락한다면 실시간 증권, 날씨, 블로그도 갱신하는 걸 만들고 싶다.