[2023 메타버스 달서 공모전] 출품을 위해, 제페토를 공부하는 일지
위 월드 샘플을 다운받은 후, 유니티로 열어 보았다.
월드 개발 무작정 따라하기 문서에 소개된 맵 3개와, 제페토에서 공식적으로 제공하는 맵, BGM, Skybox 등의 리소스 파일을 확인할 수 있었다.
그 중 campMap
을 봤는데, 별똥별이 떨어지고, 나뭇잎과 풀이 흔들리고, 불빛과 반딧불이와 별들이 반짝이고, 그릴에서 연기가 나는 등 섬세하게 구성이 되어있었다.
그 외에도 월드에 입장한 유저의 아바타 가져오기 등, 코드도 참고할 만한 것이 많이 있었다.
이 월드 샘플을 바탕으로, 필요한 부분을 적절히 가져와서 쓰면 될 것 같다.
위 가이드에 쓰인 애니메이션, 버튼 리소스를 다음 링크에서 다운 받았다.
제페토 캐릭터 생성 코드도 다시 짜 주었다.
📑 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
를 통해 구현할 수 있었다.캐릭터가 상호작용할 오브젝트 Bench
를 배치한다.
그리고 Bench
오브젝트의 자식으로 빈 오브젝트 Dock Point
를 생성한다.
해당 오브젝트와 상호작용할 포인트이다.
Dock Point
의 위치를 적절히 조절한다.
Dock Point
오브젝트의 Z축 방향이 오브젝트의 바깥쪽을 향하도록 회전한다.
Sphere Collider 컴포넌트를 추가한 후, isTrigger
에 체크한다.
Dock Point
오브젝트의 자식으로, IconPos
라는 빈 객체를 생성한다.
하이어라키 뷰에서 UI → Canvas 오브젝트를 하나 생성한 후, 이름을 Icon Canvas
로 지어 주었다.
Render Mode
: World SpaceWidth
& Height
: 각각 1로 설정Ignore Reversed Graphics
: 체크 해제zepeto-multiplay-example
프로젝트에 포함된, 상호작용 아이콘 'button_occupation.png'을 가져온다.
경로는 Asset > ZepetoInteractionSystem > WorldEventIcons > Textures
내 프로젝트에서는 Asset > Resources > Icons 폴더를 만들어 저장하였다.
Texture Type
을 Sprite (2D and UI)로 바꿔준다.Icon Canvas
오브젝트의 자식으로 UI → Button 오브젝트, Button
을 생성한다.
설정이 끝나면, 하이어라키의 Icon Canvas
를 프로젝트로 드래그하여 프리팹으로 만든 후, 하이어라키에 남아 있는 Icon Canvas
를 삭제한다.
📑 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
: 아이콘의 위치 정보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 Canvas
와 Icon Pos
를 할당한다.
실행 결과
zepeto-multiplay-example
프로젝트에 포함된, 상호작용 애니메이션 'ZW_AC_004.anim'을 가져온다.
내 프로젝트에서는 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()
함수)GetComponent<A>
를 호출하면, 게임 오브젝트에 붙어있는 A 컴포넌트를 찾는다.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
의 위치 벡터를 뺀다._playerGesturePos
를 newPos
로 갱신한다._localCharacter
의 위치를 _playerGesturePos
로 갱신하고, 방향은 Dock Point
의 방향으로 설정한다.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로 설정한다.
실행 결과