[2023 메타버스 달서 공모전] 출품을 위해, 제페토를 공부하는 일지
[Assets] 폴더에서 우클릭 → [Create] → [ZEPETO] → [Multiplay Server] 선택
그러면 [Wolrd.multiplay] 폴더가 생성된다.
index.ts
: 서버 메인 로직 코드를 담당한다.schemas
: 서버와 클라이언트 간 통신용 Data Structure를 확인할 수 있고, 인스펙터 창에서 통신에 필요한 타입을 추가할 수도 있다.World ID를 생성하는 부분은 이미 했으니 스킵
유니티 상단 메뉴 [Window] → [ZEPETO] → [Multiplay Server] 선택하면 다음과 같은 창이 뜬다.
툴바에서, [Start Multiplay Server] 버튼을 눌러 서버를 실행한다.
하이어라키 뷰에서 빈 게임 오브젝트를 생성하고, 이름을 WorldMultiplay
로 설정한다.
해당 오브젝트에 Zepeto World Multiplay
컴포넌트를 추가한다.
게임을 플레이하여, 클라이언트가 정상적으로 접속했다면 접속 로그를 확인할 수 있다.
schemas
Room State
: Room에 접속 중인 플레이어와 관련 정보, 오브젝트 위치 등을 관리하기 위한 State PropertySchema Types
: 서버-클라이언트 통신용 Data StructureSchema Types
정의number 타입의 x
, y
, z
를 필드 값으로 갖는 Vector3
를 추가한다.
방금 전에 추가한 Vector3
타입의 position
, rotation
을 필드 값으로 갖는 Transform
을 추가한다.
Player
를 추가한다. string 타입의 sessionid
, zepetoHash
, zepetoUserId
필드와, Transform
타입의 transform
필드와, number 타입의 state
필드를 가지고 있다.
Room에 입장한 플레이어 정보를 관리하기 위해서, Player
에 map 타입으로 players
를 추가한다.
onCreate(options: SandboxOptions)
onJoin(client: SandboxPlayer)
onLeave(client: SandboxPlayer, consented?: boolean)
onTick(deltaTime: number)
onJoin()
함수 작성📑 index.ts
스크립트 수정
import
import { Sandbox, SandboxOptions, SandboxPlayer } from "ZEPETO.Multiplay";
import { DataStorage } from "ZEPETO.Multiplay.DataStorage";
import { Player } from "ZEPETO.Multiplay.Schema";
onJoin()
함수
// client가 Room에 입장 시 호출
async onJoin(client: SandboxPlayer) {
// 입장한 client의 정보
console.log(`[OnJoin] sessionId: ${client.sessionId}, HashCode: ${client.hashCode}, userId: ${client.userId}`);
const player = new Player(); // 스키마에서 정의한 Player 타입
player.sessionId = client.sessionId;
if (client.hashCode) {
player.zepetoHash = client.hashCode;
}
if (client.userId) {
player.zepetoUserId = client.userId;
}
// 플레이어 데이터를 서버에 저장
const storage: DataStorage = client.loadDataStorage();
// client의 방문 횟수 저장
let visit_cnt = await storage.get("VisitCount") as number;
if (visit_cnt == null) visit_cnt = 0;
console.log(`[OnJoin] ${client.sessionId}'s visiting count: ${visit_cnt}`);
await storage.set("VisitCount", ++visit_cnt); // Player의 VisitCount를 갱신해서, Data Storage에 저장
this.state.players.set(client.sessionId, player); // 지금까지 설정한 Player의 정보를 Room State에 정의한 players에 저장
}
실행 결과
클라이언트
ClientStarter.ts
스크립트를 생성하고, 비어있는 게임 오브젝트를 생성한 후 컴포넌트로 넣어 준다.
📑 ClientStarter.ts
스크립트 작성
import
import { CharacterState, SpawnInfo, ZepetoPlayers } from 'ZEPETO.Character.Controller';
import { Room, RoomData } from 'ZEPETO.Multiplay';
import { Player, State } from 'ZEPETO.Multiplay.Schema';
import { ZepetoScriptBehaviour } from 'ZEPETO.Script';
import { ZepetoWorldMultiplay } from 'ZEPETO.World';
import * as UnityEngine from 'UnityEngine';
변수
public multiplay: ZepetoWorldMultiplay;
private room: Room;
// 클라이언트 고유 값인 sessionId로 player 객체 저장 → key: sessionId, value: player
private currentPlayers: Map<string, Player> = new Map<string, Player>();
Start()
함수
Start() {
// Room Event Listener 등록
this.multiplay.RoomCreated += (room: Room) => {
this.room = room;
};
this.multiplay.RoomJoined += (room: Room) => {
room.OnStateChange += this.OnStateChange; // State가 변할 때마다 호출되는 함수
};
}
OnStateChange()
함수
// 플레이어의 Join에 관련된 처리
private OnStateChange(state: State, isFirst: boolean) {
// ZepetoPlayers에 이벤트 리스너 등록
if (isFirst) {
// Local Player 인스턴스가 Scene에 완전히 로드되었을 때 호출된다.
ZepetoPlayers.instance.OnAddedLocalPlayer.AddListener(() => {
const myPlayer = ZepetoPlayers.instance.LocalPlayer.zepetoPlayer;
// character: 플레이에서 동적으로 생성된, 실제 Character Controller가 포함된 오브젝트
myPlayer.character.OnChangedState.AddListener((cur, prev) => {
this.SendState(cur); // 이 이벤트가 발생할 때마다 캐릭터 State(cur, prev)를 서버로 전송
});
});
}
let join = new Map<string, Player>();
// 스키마의 Room State에 저장된 플레이어 정보를 하나씩 조회
state.players.ForEach((sessionId: string, player: Player) => {
// currentPlayers가 저장된 클라이언트의 sessionId를 가지고 있지 않으면
// 방금 입장한 플레이어이니까, set으로 join에 등록
if (!this.currentPlayers.has(sessionId)) {
join.set(sessionId, player);
}
});
// Room에 새 플레이어가 입장할 때 이벤트를 받을 수 있게, player 객체에 이벤트 연결
join.forEach((player: Player, sessionId: string) => this.OnJoinPlayer(sessionId, player));
}
state
: Room State에 정의된 스키마에 접근할 수 있다.isFirst
: 첫 번째 호출을 판단한다. 두 번째 호출부터 false로 변경된다.SendState()
함수
private SendState(state: CharacterState) {
const data = new RoomData();
data.Add("state", state); // CharacterState는 ZEPETO.Character.Controller에 정의된 enum 타입 (Invalid, Idle, Walk, Run 등)
this.room.Send("onChangedState", data.GetObject()); // 클라에서 서버로 메시지 송신
}
OnJoinPlayer()
함수
// Room 입장 시 플레이어 이벤트 처리
private OnJoinPlayer(sessionId: string, player: Player) {
console.log(`[OnJoinPlayer] players - sessionId: ${sessionId}`);
// 입장한 플레이어를 관리하기 위해, 지금 입장한 플레이어를 currentPlayers에 등록
// 입장한 모든 플레이어는 currentPlayers에 등록되어, 지금 입장한 플레이어는 currentPlayers에 sessionId가 없는 경우로 판단
this.currentPlayers.set(sessionId, player);
// 플레이어 인스턴스의 초기 Transform 설정
const spawnInfo = new SpawnInfo();
const position = new UnityEngine.Vector3(0, 0, 0);
const rotation = new UnityEngine.Vector3(0, 0, 0);
spawnInfo.position = position;
spawnInfo.rotation = UnityEngine.Quaternion.Euler(rotation);
// Room과 Player의 sessionId가 같으면 Local Player이다.
const isLocal = this.room.SessionId === player.sessionId;
// 플레이어 인스턴스 생성
ZepetoPlayers.instance.CreatePlayerWithUserId(sessionId, player.zepetoUserId, spawnInfo, isLocal);
}
ClientStarter
오브젝트에 ZepetoWorldMultiplay
컴포넌트가 추가된 오브젝트를 가져와서 할당한다.
RoomCreated(Room)
RoomJoined(Room)
RoomLeave(RoomLeaveEvent)
RoomReconnected(Room)
RoomError(RoomErrorEvent)
RoomWeakConnection()
실행 결과
서버
📑 index.ts
스크립트 수정
onCreate()
함수 작성
onCreate(options: SandboxOptions) {
// 클라이언트로부터 수신된 메시지 확인
this.onMessage("onChangedState", (client, message) => {
const player = this.state.players.get(client.sessionId); // 메시지를 보낸 플레이어 정보 불러오기
player!.state = message.state;
});
}
클라이언트
📑 ClientStarter.ts
스크립트 수정
Start()
함수 수정
Start() {
// ...
// 서버에 내 위치 전송
this.StartCoroutine(this.SendMessageLoop(0.1));
}
SendMessageLoop()
함수 작성
private *SendMessageLoop(tick: number) {
while (true) {
yield new UnityEngine.WaitForSeconds(tick);
// 룸이 없거나, 룸에 연결되지 않은 경우에 대한 예외 처리
if (this.room != null && this.room.IsConnected) {
const hasPlayer = ZepetoPlayers.instance.HasPlayer(this.room.SessionId); // 로컬 플레이어의 인스턴스 존재 여부
// 인스턴스가 있으면, myPlayer 객체에 해당 인스턴스 저장
if (hasPlayer) {
const myPlayer = ZepetoPlayers.instance.GetPlayer(this.room.SessionId);
// 캐릭터가 움직이고 있는 경우
if (myPlayer.character.CurrentState != CharacterState.Idle) {
this.SendTransform(myPlayer.character.transform);
}
}
}
}
}
SendTransform()
함수 작성
private SendTransform(transform: UnityEngine.Transform) {
const data = new RoomData();
const pos = new RoomData();
pos.Add("x", transform.localPosition.x);
pos.Add("y", transform.localPosition.y);
pos.Add("z", transform.localPosition.z);
data.Add("position", pos.GetObject());
const rot = new RoomData();
rot.Add("x", transform.localEulerAngles.x);
rot.Add("y", transform.localEulerAngles.y);
rot.Add("z", transform.localEulerAngles.z);
data.Add("rotation", rot.GetObject());
// onChangedTransform 타입으로, data를 메시지로 전송한다.
this.room.Send("onChangedTransform", data.GetObject());
}
서버
📑 index.ts
스크립트 수정
onCreate()
함수 수정
onCreate(options: SandboxOptions) {
// ...
// 개별 클라이언트의 위치 수신
this.onMessage("onChangedTransform", (client, message) => {
// transform이 변경된 player를 불러온다.
const player = this.state.players.get(client.sessionId);
// 메시지에서 값 받아와서 transform에 저장
const transform = new Transform();
transform.position = new Vector3();
transform.position.x = message.position.x;
transform.position.y = message.position.y;
transform.position.z = message.position.z;
transform.rotation = new Vector3();
transform.rotation.x = message.rotation.x;
transform.rotation.y = message.rotation.y;
transform.rotation.z = message.rotation.z;
player!.transform = transform;
});
}
클라이언트
📑 ClientStarter.ts
스크립트 수정
OnStateChange()
함수 수정
private OnStateChange(state: State, isFirst: boolean) {
// ZepetoPlayers에 이벤트 리스너 등록
if (isFirst) {
// ...
// 다른 캐릭터 위치를 전송받는다.
ZepetoPlayers.instance.OnAddedPlayer.AddListener((sessionId: string) => {
const isLocal = this.room.SessionId === sessionId;
// 로컬 플레이어가 아닌 경우에만 업데이트
if (!isLocal) {
const player: Player = this.currentPlayers.get(sessionId);
// OnChange 이벤트에, 플레이어 위치를 업데이트하는 OnUpdatePlayer 함수를 연결
player.OnChange += (ChangeValues) => this.OnUpdatePlayer(sessionId, player);
}
})
}
// ...
}
ParseVector3()
함수 작성
private ParseVector3(vector3: Vector3): UnityEngine.Vector3 {
return new UnityEngine.Vector3(vector3.x, vector3.y, vector3.z);
}
OnUpdatePlayer()
함수 작성
// 플레이어의 위치를 업데이트
private OnUpdatePlayer(sessionId: string, player: Player) {
const position = this.ParseVector3(player.transform.position);
const zepetoPlayer = ZepetoPlayers.instance.GetPlayer(sessionId);
zepetoPlayer.character.MoveToPosition(position);
// CharacterState가 Jump인 경우, 실제 캐릭터도 점프하도록
if (player.state === CharacterState.JumpIdle || player.state === CharacterState.JumpMove) {
zepetoPlayer.character.Jump();
}
}
서버 & 클라이언트
📑 index.ts
스크립트 수정
onLeave()
함수 작성
// 플레이어가 Room을 떠날 때 호출
onLeave(client: SandboxPlayer, consented?: boolean) {
// 퇴장한 player를 Room에서 관리하는 State의 players 목록에서 제거
this.state.players.delete(client.sessionId);
}
📑 ClientStarter.ts
스크립트 수정
OnStateChange()
함수 수정
// 플레이어의 Join, Update, Leave에 관련된 처리
private OnStateChange(state: State, isFirst: boolean) {
// ZepetoPlayers에 이벤트 리스너 등록
if (isFirst) {
// ...
}
let join = new Map<string, Player>();
let leave = new Map<string, Player>(this.currentPlayers); // currentPlayers로 초기화
// 스키마의 Room State에 저장된 플레이어 정보를 하나씩 조회
state.players.ForEach((sessionId: string, player: Player) => {
// currentPlayers가 저장된 클라이언트의 sessionId를 가지고 있지 않으면
// 방금 입장한 플레이어이니까, set으로 join에 등록
if (!this.currentPlayers.has(sessionId)) {
join.set(sessionId, player);
}
leave.delete(sessionId); // 현재 room에 존재하는 플레이어는 모두 제거
});
// Room에 새 플레이어가 입장할 때 이벤트를 받을 수 있게, player 객체에 이벤트 연결
join.forEach((player: Player, sessionId: string) => this.OnJoinPlayer(sessionId, player));
// Room에서 플레이어가 퇴장할 때 이벤트를 받을 수 있게, player 객체에 이벤트 연결
leave.forEach((player: Player, sessionId: string) => this.OnLeavePlayer(sessionId, player));
}
OnLeavePlayer()
함수 작성
// Room 퇴장 시 플레이어 이벤트 처리
private OnLeavePlayer(sessionId: string, player: Player) {
console.log(`[OnLeavePlayer] players - sessionId: ${sessionId}`);
this.currentPlayers.delete(sessionId); // currentPlayers 목록에서 플레이어 제거
ZepetoPlayers.instance.RemovePlayer(sessionId); // 플레이어 인스턴스 제거
}
[Start Multiplay Server] 버튼을 클릭해 초록불이 들어오면, 서버 로그로 작동을 확인한다.
클라이언트 접속을 위해 플레이 버튼을 클릭하여, 콘솔과 서버 로그에서 SessionId를 확인한다.
이전에 만들어 둔 Character Controller 오브젝트를 비활성화해야 한다.
UnhandledPromiseRejectionWarning
이 뜬다. async-await에 try-catch 구문을 써야 한다는데...TS2532: Object is possibly 'undefined'.
오류도 뜨는데 왜 그런건지???index.ts
의 onCreate()
함수에서 작성한 메서드에서 메시지를 보낸 클라이언트 정보를 받아와 player에 저장하는데, 해당 player가 null인 경우도 있을 수 있어 해당 오류가 발생한 것 같았다.player!.state = massage.state;
, player!.transform = transform;
로 바꿔 주었다.실행 결과
📑 ClientStarter.ts
스크립트 수정
OnUpdatePlayer()
함수 수정
// 플레이어의 위치를 업데이트
private OnUpdatePlayer(sessionId: string, player: Player) {
const position = this.ParseVector3(player.transform.position);
const zepetoPlayer = ZepetoPlayers.instance.GetPlayer(sessionId);
// 캐릭터 이동
zepetoPlayer.character.MoveToPosition(position);
// 캐릭터 점프
if (player.state === CharacterState.Jump) {
if (zepetoPlayer.character.CurrentState !== CharacterState.Jump) {
zepetoPlayer.character.Jump();
}
}
}
실행 결과