공모전 일지5 - 의자 상호작용

Boyeong·2023년 5월 21일
0

제페토

목록 보기
5/13
post-thumbnail

230321

[2023 메타버스 달서 공모전] 출품을 위해, 제페토를 공부하는 일지

1. 제페토 공식 월드 샘플 다운

[Example] 공식 샘플 월드 Official World Samples

  • zepeto-world-sample

  • 위 월드 샘플을 다운받은 후, 유니티로 열어 보았다.

  • 월드 개발 무작정 따라하기 문서에 소개된 맵 3개와, 제페토에서 공식적으로 제공하는 맵, BGM, Skybox 등의 리소스 파일을 확인할 수 있었다.

  • 그 중 campMap을 봤는데, 별똥별이 떨어지고, 나뭇잎과 풀이 흔들리고, 불빛과 반딧불이와 별들이 반짝이고, 그릴에서 연기가 나는 등 섬세하게 구성이 되어있었다.

  • 그 외에도 월드에 입장한 유저의 아바타 가져오기 등, 코드도 참고할 만한 것이 많이 있었다.

  • 이 월드 샘플을 바탕으로, 필요한 부분을 적절히 가져와서 쓰면 될 것 같다.

2. 오브젝트 상호작용

오브젝트와 인터렉션하기

(1) 환경 세팅


  • 📑 CharacterController.ts 스크립트 수정

  • Start() 함수 수정

    // ...
    import { WorldService } from 'ZEPETO.World';
    
    export default class CharacterController extends ZepetoScriptBehaviour {
    
        Start() {
            // [ZEPETO_ID]를 접속한 사람의 닉네임으로 설정
            // ZepetoPlayers.instance.CreatePlayerWithZepetoId("", "nhpe", new SpawnInfo(), true);
            
            // 월드에 접속한 유저 각각의 고유한 아이디로 Player를 생성
            ZepetoPlayers.instance.CreatePlayerWithUserId(WorldService.userId, new SpawnInfo(), true);
            ZepetoPlayers.instance.OnAddedLocalPlayer.AddListener(() => {
                let _player : LocalPlayer = ZepetoPlayers.instance.LocalPlayer;
            });
        }
    
    }
    • 플레이어를 생성하는 부분을, 월드에 접속한 유저 각각의 고유한 아이디로 생성하도록 변경하였다.
    • 해당 부분은 제페토의 WorldService를 통해 구현할 수 있었다.

(2) 오브젝트 세팅

  • 캐릭터가 상호작용할 오브젝트 Bench를 배치한다.

    • 빌드잇 패키지에 있는 의자 중 하나를 가져왔다.
  • 그리고 Bench 오브젝트의 자식으로 빈 오브젝트 Dock Point를 생성한다.

    • 해당 오브젝트와 상호작용할 포인트이다.

    • Dock Point의 위치를 적절히 조절한다.

  • Dock Point 오브젝트의 Z축 방향이 오브젝트의 바깥쪽을 향하도록 회전한다.

    • 이때, 유니티 에디터 상단의 Gizmo 토글 버튼이 Local로 되어있어야 방향을 확인하기 쉽다.
    • Gizmo Local vs Global
  • Sphere Collider 컴포넌트를 추가한 후, isTrigger에 체크한다.

    • 플레이어가 오브젝트와 상호작용할 수 있는 범위를 가늠하여, Collider의 크기를 정한다.

(3) UI 세팅

  • Dock Point 오브젝트의 자식으로, IconPos라는 빈 객체를 생성한다.

    • 상호작용이 가능한 오브젝트 근처에 있을 때 나타나는 아이콘 같은데, Transform을 일단 가이드대로 맞춰두었다.
  • 하이어라키 뷰에서 UI → Canvas 오브젝트를 하나 생성한 후, 이름을 Icon Canvas로 지어 주었다.

    • Render Mode: World Space
    • Width & Height: 각각 1로 설정
    • Ignore Reversed Graphics: 체크 해제
  • zepeto-multiplay-example 프로젝트에 포함된, 상호작용 아이콘 'button_occupation.png'을 가져온다.

    • 경로는 Asset > ZepetoInteractionSystem > WorldEventIcons > Textures

    • 내 프로젝트에서는 Asset > Resources > Icons 폴더를 만들어 저장하였다.

  • button_occupation.png를 클릭해, Texture Type을 Sprite (2D and UI)로 바꿔준다.
  • 설정이 끝나면, 하이어라키의 Icon Canvas를 프로젝트로 드래그하여 프리팹으로 만든 후, 하이어라키에 남아 있는 Icon Canvas를 삭제한다.

(4) UI 관련 스크립트 작성


  • 📑 InteractionIcon.ts 스크립트 생성

  • import

    import { Camera, Canvas, Collider, GameObject, Object, Transform } from 'UnityEngine'
    import { UnityEvent } from 'UnityEngine.Events';
    import { Button } from 'UnityEngine.UI';
    import { ZepetoPlayers } from 'ZEPETO.Character.Controller';
    import { ZepetoScriptBehaviour } from 'ZEPETO.Script'
  • 변수

    // Icon
    @Header("[Icon]")
    @SerializeField() private PrefIconCanvas: GameObject;   // 아이콘 캔버스 프리팹
    @SerializeField() private iconPos: Transform;           // 아이콘의 위치
    
    // Unity Event
    @Header("[Unity Event]")
    public OnClickEvent: UnityEvent;
    public OnTriggerEnterEvent: UnityEvent;
    public OnTriggerExitEvent: UnityEvent;
    
    private _canvas: Canvas;
    private _button: Button;
    private _cacheWorldCamera: Camera;
    private _isIconActive: boolean = false;
    private _isDoneFirstTrig: boolean = false;
    • @Header(""): 인스펙터 창에서 헤더를 표시해 준다.
    • PrefIconCanvas: 방금 만든 아이콘 캔버스의 프리팹
    • IconPos: 아이콘의 위치 정보
    • Unity C# > 이벤트 함수 정리
      • OnClickEvent: 버튼 클릭 이벤트
      • OnTriggerEnterEvent: Trigger인 콜라이더와 충돌하는 순간 실행되는 이벤트
      • OnTriggerExitEvent: Trigger인 콜라이더와 떼어지는 순간 실행되는 이벤트
    • _canvas: PrefIconCanvas 프리팹과 IconPos 위치 정보로 캔버스를 복제한다.
    • _button: _canvas의 자식으로 있는 버튼
    • _cacheWorldCamera: ZepetoPlayers 오브젝트의 카메라 컴포넌트를 저장한다. (렌더 모드가 World Space인 캔버스는 worldCamera라는 속성이 있는데, 그 속성에 이 변수를 할당한다.)
    • _isIconActive: 상호작용 아이콘을 보여주면 true, 숨기면 false
    • _isDoneFirstTrig: 오브젝트와 한 번도 상호작용을 하지 않은 상태에서, 첫 번째 상호작용을 하면 캔버스 프리팹의 복제본을 만들고 true로 값이 변경된다.
  • Update() 함수

    private Update() {
        if (this._isDoneFirstTrig && this._canvas?.gameObject.activeSelf) {
            this.UpdateIconRotation();  // 아이콘 캔버스를 카메라 회전에 맞게 회전
        }
    }
    • _isDoneFirstTrig가 true이고 _canvas가 활성화되어 있으면 (= 상호작용 아이콘이 보이면)
    • 매 프레임마다, 아이콘 캔버스를 카메라 회전에 맞게 회전시킨다. (UpdateIconRotation() 함수)
  • OnTriggerEnter(coll: Collider) 함수

    // 콜라이더 영역 내로 캐릭터가 들어온 경우, 즉 충돌하는 순간 호출
    private OnTriggerEnter(coll: Collider) {
        // 상호작용 가능 범위(콜라이더 영역 내)에 들어온 객체가 플레이어가 아니면, 함수 종료
        if (coll != ZepetoPlayers.instance.LocalPlayer?.zepetoPlayer?.character.GetComponent<Collider>()) {
            return;
        }
    
        // 맞으면, 상호작용 아이콘 활성화
        this.ShowIcon();
        this.OnTriggerEnterEvent?.Invoke();
    }
    • 상호작용 오브젝트의 콜라이더에 다른 오브젝트(coll)가 충돌한 순간 호출된다.
    • coll이 플레이어가 아니면, 함수를 종료한다.
    • coll이 플레이어가 맞으면, 상호작용 아이콘을 활성화한다. (ShowIcon() 함수)
  • OnTriggerExit(coll: Collider) 함수

    // 콜라이더 영역 밖으로 캐릭터가 나온 경우, 즉 충돌한 후 떨어지는 순간 호출
    private OnTriggerExit(coll: Collider) {
        if (coll != ZepetoPlayers.instance.LocalPlayer?.zepetoPlayer?.character.GetComponent<Collider>()) {
            return;
        }
    
        this.HideIcon();    // 상호작용 아이콘 비활성화
        this.OnTriggerExitEvent?.Invoke();
    }
    • 상호작용 오브젝트의 콜라이더에서 다른 오브젝트(coll)가 떨어지는 순간 호출된다.
    • coll이 플레이어가 아니면, 함수를 종료한다.
    • coll이 플레이어가 맞으면, 상호작용 아이콘을 비활성화한다. (HideIcon() 함수)
  • ShowIcon() 함수

    // 아이콘 보이기
    public ShowIcon() {
        // 첫 실행 시 아이콘이 없는 경우, 아이콘 먼저 생성
        if (!this._isDoneFirstTrig) {
            this.CreateIcon();
            this._isDoneFirstTrig = true;
        } else {
            this._canvas.gameObject.SetActive(true);
        }
    
        this._isIconActive = true;
    }
    • _isDoneFirstTrig 값이 false인 경우, 아이콘을 새로 생성한 후 (CreateIcon() 함수), _isDoneFirstTrig 값을 true로 변경한다.
    • 그렇지 않은 경우, _canvas를 활성화하여 아이콘을 보여 준다.
    • _isIconActive 값을 true로 변경한다.
  • HideIcon() 함수

    // 아이콘 숨기기
    public HideIcon() {
        this._canvas?.gameObject.SetActive(false);
        this._isIconActive = false;
    }
    • _canvas를 비활성화하여 아이콘을 숨긴다.
    • _isIconActive 값을 false로 변경한다.
  • CreateIcon() 함수

    private CreateIcon() {
        // 하이어라키 뷰에 Canvas가 없으므로, 첫 실행 시는 undefined
        // 따라서 미리 만든 프리팹을 통해 새 캔버스와 버튼을 만들어 준다.
        if (this._canvas === undefined) {
            const canvas = GameObject.Instantiate(this.PrefIconCanvas, this.IconPos) as GameObject;
    
            this._canvas = canvas.GetComponent<Canvas>();
            this._button = canvas.GetComponentInChildren<Button>();
            this._canvas.transform.position = this.IconPos.position;
        }
    
        this._cacheWorldCamera = Object.FindObjectOfType<Camera>();
        this._canvas.worldCamera = this._cacheWorldCamera;
    
        this._button.onClick.AddListener(() => {
            this.OnClickIcon();
        });
    }
    • _canvas 오브젝트가 만들어지지 않았다면, 프리팹을 통해 복제해서 게임 오브젝트를 하나 만든다.
    • _canvas_button을 만들고, _canvas의 위치 정보를 IconPos의 값으로 설정한다.
    • ZepetoPlayers의 카메라 컴포넌트를 가져와서, _canvas의 worldCamera로 설정한다.
    • _button에 onClick 이벤트 리스너를 등록해서, 버튼을 클릭하면 OnClickIcon() 함수를 실행하도록 한다.
  • UpdateIconRotation() 함수

    private UpdateIconRotation() {
        this._canvas.transform.LookAt(this._cacheWorldCamera.transform);
    }
    • _canvas가 카메라가 바라보는 위치를 바라보도록 회전시킨다.
  • OnClickIcon() 함수

    private OnClickIcon() {
        this.OnClickEvent?.Invoke();
    }
    • _button을 클릭하면, 유니티 이벤트 함수인 OnClickEvent()를 실행하도록 한다. (아직 내용은 X)

  • Dock Point 오브젝트에 스크립트를 추가하고, 인스펙터 창에서 Pref Icon CanvasIcon Pos를 할당한다.

  • 실행 결과

(5) 상호작용 제스처 관련 스크립트

  • zepeto-multiplay-example 프로젝트에 포함된, 상호작용 애니메이션 'ZW_AC_004.anim'을 가져온다.

    • 경로는 Asset > ZepetoInteractionSystem > Animations
  • 내 프로젝트에서는 Asset > Resources > Animations 폴더를 만들어, 'gesture_sit.anim'이라는 이름으로 저장하였다.


  • 📑 InteractionGesture.ts 스크립트 생성

  • import

    import { AnimationClip, Animator, HumanBodyBones, Physics, Transform, Vector3, WaitForEndOfFrame } from 'UnityEngine'
    import { ZepetoCharacter, ZepetoPlayers } from 'ZEPETO.Character.Controller';
    import { ZepetoScriptBehaviour } from 'ZEPETO.Script'
    
    import InteractionIcon from './InteractionIcon'
    • InteractionGesture.ts 스크립트를 InteractionIcon.ts 스크립트와 같은 위치 (Interaction 폴더)에 생성한다.
  • 변수

    @SerializeField() private animationClip: AnimationClip;     // 앉는 제스처 애니메이션
    @SerializeField() private isSnapBone: boolean = true;       // bodyBone의 위치를 Dock Point에 딱 붙게 할 것인지
    @SerializeField() private bodyBone: HumanBodyBones;         // Dock Point에 닿게할 신체 부분 (Hips)
    @SerializeField() private allowOverlap: boolean = false;    // 한 자리에 여러 명이 겹쳐 앉을 수 있는지
    
    private _interactionIcon: InteractionIcon;
    private _localCharacter: ZepetoCharacter;   // Player
    private _outPos: Vector3;   // 플레이어가 일어나는 위치 (= Dock Point의 위치)
    private _playerGesturePos: Vector3;
    • animationClip: 앉는 제스처 애니메이션
    • isSnapBone: bodyBone의 위치를 Dock Point에 딱 붙일 것인지 체크
      • 체크하지 않으면, 콜라이더 근처라면 아무렇게나(말 그대로...) 앉게 된다.
    • bodyBone: Dock Point에 닿게할 신체 부위 (이 경우는 엉덩이)
    • allowOverlap: 한 자리에 여러 명이 겹쳐 앉을 수 있는지 체크
    • _interactionIcon: InteractionIcon의 함수를 사용하기 위해 불러온다.
    • _localCharacter: 플레이어를 의미한다.
    • _outPos: 플레이어가 의자에서 일어날 때, 서 있게 되는 위치 (= Dock Point의 위치)
    • _playerGesturePos: 정확한 의미를 잘 모르겠지만, 앉는 제스처를 취할 위치 정보를 저장하고 있는 것 같다.
  • Start() 함수

    Start() {    
        this._interactionIcon = this.transform.GetComponent<InteractionIcon>();
    
        // 로컬 플레이어가 정상적으로 생성되면, 그 플레이어를 this._localCharacter로 설정
        ZepetoPlayers.instance.OnAddedLocalPlayer.AddListener(() => {
            this._localCharacter = ZepetoPlayers.instance.LocalPlayer.zepetoPlayer.character;
        });
    
        // 상호작용 아이콘을 클릭하면, 아이콘 비활성화 & DoInteraction() 함수 호출
        this._interactionIcon.OnClickEvent.AddListener(() => {
            this._interactionIcon.HideIcon();
            this.DoInteraction();
        })
    }
    • 로컬 플레이어의 정보를 _localCharacter에 저장한다.
    • 상호작용 아이콘을 클릭하면, 아이콘을 비활성화하고, 앉는 상호작용을 한다. (DoInteraction() 함수)
    • + 의문
      • 왜 this.GetComponent가 아니라, this.transform.GetComponent일까?
      • Unity: transform.GetComponent Vs. GameObject.GetComponent?
        • 게임 오브젝트에서 GetComponent<A>를 호출하면, 게임 오브젝트에 붙어있는 A 컴포넌트를 찾는다.
        • 컴포넌트(ex. Transform)에서 GetComponent<A>를 호출하면, 그 컴포넌트(ex. Transform)를 가지고 있는 게임 오브젝트를 찾은 후, 그 오브젝트에 붙어있는 A 컴포넌트를 찾는다.
        • 성능 차이가 그렇게 나진 않을 거라고 한다.
  • DoInteraction() 함수

    private DoInteraction() {
        this._outPos = this.transform.position;
    
        // 의자에 엉덩이를 딱 붙이고, 방향도 제대로 보게 하고 싶을 때
        if (this.isSnapBone) {
            // 여러 명이 겹쳐 앉을 수 있거나, 자리가 비어있으면
            if (this.allowOverlap || this.FindOtherPlayerNum() < 1) {
                this._localCharacter.SetGesture(this.animationClip);    // 앉는 제스처 실행
                this.StartCoroutine(this.SnapBone());
                this.StartCoroutine(this.WaitForExit());
            } else {
                // 자리가 다 찼으면
                this._interactionIcon.ShowIcon();   // 상호작용 아이콘만 활성화
            }
        } else {
            // 의자에 아무렇게나 앉아있어도 될 때
            this._localCharacter.SetGesture(this.animationClip);
            this.StartCoroutine(this.WaitForExit());
        }
    }
    • isSnapBone이 true인 경우
      • 여러 명이 겹쳐 앉아 있을 수 있는 경우이거나, 자리가 비어있는 경우 (= 앉을 수 있는 경우)
        • 앉는 제스처를 취한다.
        • 앉는 제스처를 취할 때, 의자에 엉덩이를 딱 붙이고 방향도 앞을 바라보게 한다. 그리고 대기한다. (SnapBone() 코루틴)
        • 계속 앉아 있으면서, 언제든 일어날 수 있도록 사용자의 입력이 있을 때까지 대기한다. (WaitForExit() 코루틴)
      • 자리가 다 찬 경우 (= 앉을 수 없는 경우)
        • 상호작용 아이콘만 활성화한다.
    • isSnapBone이 false인 경우
      • 앉는 제스처를 취한다.
      • 앉는 위치와 방향은 상관없으므로, SnapBone() 코루틴을 시작하지 않는다.
      • 계속 앉아 있으면서, 언제든 일어날 수 있도록 사용자의 입력이 있을 때까지 대기한다. (WaitForExit() 코루틴)
  • ❣️SnapBone() 코루틴

    // Dock Point에 bodyBone 붙이기
    private *SnapBone() {
        const animator: Animator = this._localCharacter.ZepetoAnimator;
        const bone: Transform = animator.GetBoneTransform(this.bodyBone);   // 엉덩이의 위치 정보
    
        const wait: WaitForEndOfFrame = new WaitForEndOfFrame();    // 캐싱 (반복문마다 new를 사용하면 메모리 낭비)
        let idx = 0;
    
        while (true) {
            const distance = Vector3.op_Subtraction(bone.position, this._localCharacter.transform.position);    // 엉덩이의 위치와 캐릭터의 위치의 차이
            const newPos: Vector3 = Vector3.op_Subtraction(this.transform.position, distance);  // Dock Point의 위치와 distance의 차이
    		
            // console.log(newPos);
            // console.log(this._localCharacter.transform.position)
            
            this._playerGesturePos = newPos;    // 제스처를 취할 위치를 새로 갱신
            this._localCharacter.transform.position = this._playerGesturePos;   // 캐릭터의 위치를 새로 갱신
            this._localCharacter.transform.rotation = this.transform.rotation;
    
            yield wait;  // 모든 카메라, GUI의 렌더링 작업이 끝날 때까지 대기
            idx++;
    
            // 애니메이션의 5프레임동안 위치를 보정 (?)
            if (idx > 5) {
                return;
            }
        }
    }
    • animator: 플레이어의 제페토 애니메이터
    • bone: animator에서, bodyBone (= Hips)의 Transform 정보를 저장
    • idx: 프레임을 세기 위한 변수
    • 무한 반복문
      • distance: 엉덩이의 위치 벡터에서, 플레이어의 위치 벡터를 뺀다.
      • newPos: Dock Point의 위치 벡터에서, distance의 위치 벡터를 뺀다.
      • _playerGesturePosnewPos로 갱신한다.
      • _localCharacter의 위치를 _playerGesturePos로 갱신하고, 방향은 Dock Point의 방향으로 설정한다.
    • 한 프레임이 모두 종료되고, 모든 렌더링 작업이 끝날 때까지 대기한다.
    • idx 값을 1 증가한다. (= 프레임이 1 증가)
    • 5프레임 동안은 앉아있는 위치에 캐릭터를 고정한다. (조건문의 숫자를 늘려보면, 해당 프레임이 될 때까지 의자에서 벗어날 수 없는 것을 확인할 수 있었다.)
  • distance(빨간 선)와 newPos(파란 선)가 어디를 나타내는지 궁금해서 기즈모를 그려보았다. (근데 그래도 모르겠다...)

  • FindOtherPlayerNum() 함수

    // 최적화를 위해 로컬 클라이언트로 계산되었으나, 정확하게 하려면 서버 코드를 살펴봐야 함.
    private FindOtherPlayerNum() {
        // Dock Point 위치에 존재하는 오브젝트들
        const hitInfos = Physics.OverlapSphere(this.transform.position, 0.1);
    
        let playerNum = 0;  // 앉아있는 제페토 캐릭터들
        if (hitInfos.length > 0) {
            hitInfos.forEach((hitInfo) => {
                // 그 오브젝트가 제페토 캐릭터이면, 수를 1 증가
                if (hitInfo.transform.GetComponent<ZepetoCharacter>()) {
                    playerNum++;
                }
            })
        }
    
        return playerNum;
    }
    • hitInfos: Dock Point의 위치에 있는 모든 오브젝트들의 정보를 저장
      • OverlapSphere(): 중점과 반지름으로 가상의 원을 만들어, 반경에 있는 콜라이더들을 반환한다.
    • playerNum: hitInfos 중 제페토 캐릭터의 수만 저장한다.
    • 반복문을 돌며, hitInfo의 컴포넌트 내에 ZepetoCharacter가 있으면, playerNum의 수를 1 증가한다.
  • WaitForExit() 코루틴

    private *WaitForExit() {
        if (this._localCharacter) {
            while (true) {
                // 캐릭터가 점프하거나 움직이면
                if (this._localCharacter.tryJump || this._localCharacter.tryMove) {
                    this._localCharacter.CancelGesture();   // 제스처를 취소
                    this.transform.position = this._outPos;
                    this._interactionIcon.ShowIcon();       // 아이콘 활성화
                    break;
                } else if (this.isSnapBone && this._playerGesturePos != this._localCharacter.transform.position) {
                    this._interactionIcon.ShowIcon();
                    break;
                }
    
                yield;
            }
        }
    }
    • 캐릭터가 점프하거나 움직이는 경우
      • 앉아있는 제스처를 취소한다.
      • 상호작용 아이콘을 활성화한다.
    • 캐릭터가 콜라이더 영역을 벗어나면
      • 상호작용 아이콘을 활성화한다.

  • Dock Point 오브젝트에 스크립트를 추가하고, 인스펙터 창에서 Animation Clip을 할당하고, Is Snap Bone에 체크한 후, Body Bone을 Hips로 설정한다.

  • 실행 결과

3. 다음에 할 일

4. 후기

  • 상호작용 들어오니까 갑자기 코드가 확 길어져서 당황스러웠는데, 하나하나 의미를 이해해 보려고 노력했지만 쉽지 않았다. 며칠 걸려서 해석해 보려고 했지만 정확한지도 모르겠고 가이드 외에는 구글링을 해도 자료가 잘 안 나와서 힘들다.
  • 그래도 SerializeField 사용법이나, 타입스크립트에서 코루틴을 선언할 땐 *을 붙인다는 점 등, 새로운 것도 많이 알게 되어 흥미로웠다.
  • 다음엔 본격적으로 맵을 만들 것 같다. 무료 에셋으로 다 만들어지면 좋겠다.

0개의 댓글