이 클라이언트 라이브리러 가이드에서는 Among Us(외부)에서 영감을 받은 Sagi-shi(“사기꾼"을 뜻하는 일본어)라는 게임에서 Nakama 고유의 부품을 개발하는 방법(전체 게임 로직이나 UI를 사용하지 않음)을 통해 Unity에서 Nakama의 핵심 기능을 사용하는 방법에 대해서 설명합니다.
이 가이드에서는 Unity의 내장 PlayerPrefs을(를) 사용하여 장치 ID, 인증 토큰, 캐시 가능한 커서와 같은 데이터를 저장하고 로드합니다. .NET SDK를 사용하는 경우 디스크에 직접 저장하는 등 데이터 저장/검색 방법을 자유롭게 선택하여 사용할 수 있습니다.
Nakama에는 실패한 API 호출의 재시도 방법을 제어하는 전역 또는 요청별 RetryConfiguration 객체가 있습니다. 재시도 패턴은 인증 또는 백그라운드 서비스 관련 작업에 유용할 수 있습니다.
Sagi-shi는 전역 재시도 구성을 사용하여 실패한 API 호출을 최대 5번까지 시도한 후 콘솔에 오류를 출력합니다.
개별 요청에 RetryConfiguration 객체를 전달하면 전역적으로 설정된 구성이 재정의됩니다.
1
2
3
4
5
6
7
8
varretryConfiguration=newNakama.RetryConfiguration(baseDelay:1,maxRetries:5,delegate{System.Console.Writeline("about to retry.");});// Configure the retry configuration globally.client.GlobalRetryConfiguration=retryConfiguration;varaccount=awaitclient.GetAccountAsync(session);// Alternatively, pass the retry configuration to an individual request.varaccount=awaitclient.GetAccountAsync(session,retryConfiguration);
재시도 요청 사이에 일정한 시간 만큼 시차를 두는 것이 유용한 경우도 있습니다. 이를 위해 새 RetryConfiguration을(를) 만들 때 jitter 인수에 대리자를 전달할 수 있습니다. 완전 무작위 재시도 지터의 경우 SDK의 일부로 제공되는 RetryJitter.FullJitter 대리자를 사용할 수 있습니다.
1
2
// Retry configuration with random jitter intervalsvarretryConfiguration=newRetryConfiguration(500,5,RetryListener,RetryJitter.FullJitter);
요청 사이의 지연을 더 많이 제어해야 하는 경우 사용자만의 지터 대리자를 제공할 수 있습니다.
1
2
3
4
5
6
7
8
9
// Retry configuration with custom retry jittervarretryConfigurationCustomJitter=newRetryConfiguration(500,5,RetryListener,(history,baseDelay,random)=>{// Use the "Decorrelated Jitter" algorithm (https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)constintdelayCap=20000;varlastAttempt=history.Last();varjitter=Mathf.Min(delayCap,random.Next(baseDelay,lastAttempt.JitterBackoff*3));returnjitter;});
Nakama API는 요청을 취소하는 데 사용할 수 있는 선택적 CancellationTokenSource 객체를 사용할 수 있습니다.
1
2
3
4
5
// Part of System.Threading namespacevarcanceller=newCancellationTokenSource();varaccount=awaitclient.GetAccountAsync(session,retryConfiguration:null,canceller);canceller.Cancel();
System.Runtime.Serialization.Formatters.Binary 네임스페이스 사용. Base64로(으로부터)의 변환은 직렬화된 데이터를 string(으)로 보내고 받는 경우에만 필요하며 그렇지 않은 경우 byte[] 배열을 사용하여 직렬화 및 역직렬화할 수 있습니다.
publicasyncvoidAuthenticateWithDevice(){// If the user's device ID is already stored, grab that - alternatively get the System's unique device identifier.vardeviceId=PlayerPrefs.GetString("deviceId",SystemInfo.deviceUniqueIdentifier);// If the device identifier is invalid then let's generate a unique one.if(deviceId==SystemInfo.unsupportedIdentifier){deviceId=System.Guid.NewGuid().ToString();}// Save the user's device ID to PlayerPrefs so it can be retrieved during a later play session for re-authenticating.PlayerPrefs.SetString("deviceId",deviceId);// Authenticate with the Nakama server using Device Authentication.try{Session=awaitclient.AuthenticateDeviceAsync(deviceId);Debug.Log("Authenticated with Device ID");}catch(ApiResponseExceptionex){Debug.LogFormat("Error authenticating with Device ID: {0}",ex.Message);}}
publicvoidAuthenticateWithFacebook(){FB.LogInWithReadPermissions(new[]{"public_profile","email"},asyncresult=>{if(FB.IsLoggedIn){try{varimportFriends=true;Session=awaitclient.AuthenticateFacebookAsync(AccessToken.CurrentAccessToken.TokenString,importFriends);Debug.Log("Authenticated with Facebook");}catch(ApiResponseExceptionex){Debug.LogFormat("Error authenticating with Facebook: {0}",ex.Message);}}});}
publicasyncvoidAuthenticationWithCustom(){// Authenticate using Custom ID (using itch.io authentication).try{varitchioApiKey=Environment.GetEnvironmentVariable("ITCHIO_API_KEY");Session=awaitClient.AuthenticateCustomAsync(itchioApiKey);Debug.Log("Authenticated with Custom ID");}catch(ApiResponseExceptionex){Debug.LogFormat("Failed authentication: {0}",ex.Message);}}
사용자 인증을 완료한 경우, 사용자는 계정에서 Nakama 연결 인증 방법을 사용할 수 있습니다.
장치 ID 인증 연결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
publicasyncvoidLinkDeviceAuthentication(){// Acquiring the unique device ID has been shortened for brevity, see previous example.vardeviceId="<UniqueDeviceId>";// Link Device Authentication to existing player account.try{awaitclient.LinkDeviceAsync(Session,deviceId);Debug.Log("Successfully linked Device ID authentication to existing player account");}catch(ApiResponseExceptionex){Debug.LogFormat("Error linking Device ID: {0}",ex.Message);}}
Facebook 인증 연결
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
publicvoidLinkFacebookAuthentication(boolimportFriends=true){FB.LogInWithReadPermissions(new[]{"public_profile","email"},asyncresult=>{if(FB.IsLoggedIn){try{varimportFriends=true;awaitclient.LinkFacebookAsync(Session,AccessToken.CurrentAccessToken.TokenString,importFriends);Debug.Log("Successfully linked Facebook authentication to existing player account");}catch(ApiResponseExceptionex){Debug.LogFormat("Error authenticating with Facebook: {0}",ex.Message);}}});}
// Check whether a session has expired or is close to expiry.if(session.IsExpired||session.HasExpired(DateTime.UtcNow.AddDays(1))){try{// Attempt to refresh the existing session.session=awaitclient.SessionRefreshAsync(session);}catch(ApiResponseException){// Couldn't refresh the session so reauthenticate.session=awaitclient.AuthenticateDeviceAsync(deviceId);PlayerPrefs.SetString("nakama.refreshToken",session.RefreshToken);}PlayerPrefs.SetString("nakama.authToken",session.AuthToken);}
publicclassMetadata{publicstringTitle;publicstringHat;publicstringSkin;}// Get the updated account object.varaccount=awaitclient.GetAccountAsync(session);// Parse the account user metadata.varmetadata=Nakama.TinyJson.JsonParser.FromJson<Metadata>(account.User.Metadata);Debug.LogFormat("Title: {0}",metadata.Title);Debug.LogFormat("Hat: {0}",metadata.Hat);Debug.LogFormat("Skin: {0}",metadata.Skin);
쓰기 로직을 어디에 둘 것인지 결정할 때 악의적인 사용자가 게임과 재무 상태에 어떤 역효과를 줄 수 있는지 생각하십시오. 예를 들어 정식으로만 작성해야 하는 데이터(예: 게임 잠금 해제 또는 진행 상황).
Sagi-shi에서 플레이어는 UI를 통해 더 쉽게 액세스할 수 있도록 즐겨찾기 항목을 사용할 수 있으며 클라이언트로부터 이 데이터를 쓰는 것이 안전합니다.
컬렉션 이름, 키, JSON 인코딩 데이터를 사용하여 저장소 객체 작성하기를 생성합니다. 마지막으로, 저장소 엔진에 저장소 객체를 작성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
varfavoriteHats=newHatsStorageObject{Hats=newstring[]{"cowboy","alien"}};varwriteObject=newWriteStorageObject{Collection="favorites",Key="Hats",Value=JsonWriter.ToJson(favoriteHats),PermissionRead=1,// Only the server and owner can readPermissionWrite=1,// The server and owner can write};awaitclient.WriteStorageObjectsAsync(session,new[]{writeObject});
WriteStorageObjectsAsync 메서드에 여러 개의 객체를 전달할 수 있습니다.
저장소 엔진 조건부 작성은 저장소 엔진에 액세스한 후에 객체가 변경되지 않은 경우에만 발생합니다.
이렇게 하면 데이터 덮어쓰기를 방지할 수 있습니다. 예를 들어, 플레이어가 마지막으로 액세스한 이후에 Sagi-shi 서버가 객체를 업데이트 했을 수도 있습니다.
조건부 작성을 실행하려면 버전을 추가하여 가장 최신의 객체 버전에서 저장소 객체를 작성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Assuming we already have a storage object (storageObject)varwriteObject=newWriteStorageObject{Collection=storageObject.Collection,Key=storageObject.Key,Value="<NewJSONValue>",PermissionWrite=0,PermissionRead=1,Version=storageObject.Version};try{awaitclient.WriteStorageObjectsAsync(session,writeObjects);}catch(ApiResponseExceptionex){Debug.Log(ex.Message);}
varlimit=3;varunlocksObjectList=awaitclient.ListUsersStorageObjectsAsync(session,"Unlocks",session.UserId,limit,cursor:null);foreach(varunlockStorageObjectinunlocksObjectList.Objects){switch(unlockStorageObject.Key){case"Titles":varunlockedTitles=JsonParser.FromJson<TitlesStorageObject>(unlockStorageObject.Value);// Display the unlocked titlesbreak;case"Hats":varunlockedHats=JsonParser.FromJson<HatsStorageObject>(unlockStorageObject.Value);// Display the unlocked hatsbreak;case"Skins":varunlockedSkins=JsonParser.FromJson<SkinsStorageObject>(unlockStorageObject.Value);// Display the unlocked skinsbreak;}}
클라이언트에서 Nakama 원격 프로시저를 호출하여 JSON 페이로드를 선택할 수 있습니다.
Sagi-shi 클라이언트는 모자를 안전하게 구비하기 위해서 RPC를 만듭니다:
1
2
3
4
5
6
7
8
9
10
try{varpayload=newDictionary<string,string>{{"item","cowboy"}};varresponse=awaitclient.RpcAsync(session,"EquipHat",payload.ToJson());Debug.Log("New hat equipped successfully",response);}catch(ApiResponseExceptionex){Debug.LogFormat("Error: {0}",ex.Message);}
Nakama에서 친구를 추가해도 상호적인 친구 관계가 즉각적으로 추가되지 않습니다. 사용자별 진행중인 친구 요청은 사용자가 승인해야 합니다.
Sagi-shi에서는 플레이어가 사용자 이름이나 사용자 ID를 통해 친구를 추가할 수 있습니다:
1
2
3
4
5
// Add friends by Username.awaitclient.AddFriendsAsync(session,null,new[]{"AlwaysTheImposter21","SneakyBoi"});// Add friends by User ID.awaitclient.AddFriendsAsync(session,new[]{"<SomeUserId>","<AnotherUserId>"});
개발자는 Nakama에서 친구 관계 상태를 기반으로 플레이어의 친구 목록을 만들 수 있습니다.
Sagi-shi에서는 20명의 가장 최근 친구가 목록으로 표시됩니다:
1
2
3
4
5
6
7
8
varlimit=20;// Limit is capped at 1000varfrienshipState=0;varresult=awaitclient.ListFriendsAsync(session,frienshipState,limit,cursor:null);foreach(varfriendinresult.Friends){Debug.LogFormat("ID: {0}",friend.User.Id);}
Sagi-shi 플레이어는 사용자 이름이나 사용자 ID를 통해 친구를 삭제할 수 있습니다:
1
2
3
4
5
// Delete friends by User ID.awaitclient.DeleteFriendsAsync(session,new[]{"<SomeUserId>","<AnotherUserId>"});// Delete friends by Username.awaitclient.DeleteFriendsAsync(session,null,new[]{"<SomeUsername>","<AnotherUsername>"});
Sagi-shi 플레이어는 사용자 이름이나 사용자 ID를 통해 다른 사용자를 차단할 수 있습니다:
1
2
3
4
5
// Block friends by User ID.awaitclient.BlockFriendsAsync(session,new[]{"<SomeUserId>","<AnotherUserId>"});// Block friends by Username.awaitclient.BlockFriendsAsync(session,null,new[]{"<SomeUsername>","<AnotherUsername>"});
// Subscribe to the Status event.socket.ReceivedStatusPresence+=e=>{foreach(varpresenceine.Joins){Debug.LogFormat("{0} is online with status: '{1}'",presence.Username,presence.Status);}foreach(varpresenceine.Leaves){Debug.LogFormat("{0} went offline",presence.Username);}};// Follow mutual friends and get the initial Status of any that are currently online.varfriendsResult=awaitclient.ListFriendsAsync(session,0);varfriendIds=friendsResult.Friends.Select(f=>f.User.Id);varresult=awaitsocket.FollowUsersAsync(friendIds);foreach(varpresenceinresult.Presences){Debug.LogFormat("{0} is online with status: {1}",presence.Username,presence.Status);}
그룹에는 공용 또는 개인 “공개” 표시가 있습니다. 누구나 공용 그룹에 가입할 수 있지만 가입을 요청하고 비공개 그룹의 최고 관리자/관리자가 수락해야 합니다.
Sagi-shi 플레이어는 공통의 관심사를 기반으로 그룹을 생성할 수 있습니다:
1
2
3
4
5
6
varname="Imposters R Us";vardescription="A group for people who love playing the imposter.";varopen=true;// public groupvarmaxSize=100;vargroup=awaitclient.CreateGroupAsync(session,name,description,avatarUrl:null,langTag:null,open,maxSize);
그룹은 다른 Nakama 리소스와 같이 목록을 만들고 와일드카드 그룹 이름으로 필터링할 수 있습니다.
Sagi-shi 플레이어는 그룹 목록과 필터링을 통해 기존 그룹을 검색할 수 있습니다:
1
2
3
4
5
6
7
8
9
10
varlimit=20;varresult=awaitclient.ListGroupsAsync(session,"imposter%",limit);foreach(vargroupinresult.Groups){Debug.LogFormat("{0} [{1}]",group.Name,group.Open?"Public":"Private")}// Get the next page of results.varnextResults=awaitclient.ListGroupsAsync(session,name:"imposter%",limit,result.Cursor);
Sagi-shi 그룹 구성원은 지속적인 그룹 채팅 채널에서 플레이 세션 동안 대화할 수 있습니다:
1
2
3
4
5
6
vargroupId="<GroupId>";varpersistence=true;varhidden=false;varchannel=awaitsocket.JoinChatAsync(groupId,ChannelType.Group,persistence,hidden);Debug.LogFormat("Connected to group channel: {0}",channel.Id);
Sagi-shi 플레이어는 대결 중 또는 대결 후에 개인적으로 1:1 채팅을 하고 이전 메시지를 볼 수도 있습니다:
1
2
3
4
5
6
varuserId="<UserId>";varpersistence=true;varhidden=false;varchannel=awaitsocket.JoinChatAsync(userId,ChannelType.DirectMessage,persistence,hidden);Debug.LogFormat("Connected to direct message channel: {0}",channel.Id);
모든 유형의 채팅 채널에서 메시지를 보내는 방법은 동일합니다. 메시지에는 채팅 문자와 이모티콘이 포함되며 JSON 직렬 데이터로 전송됩니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
varchannelId="<ChannelId>"varmessageContent=newDictionary<string,string>{{"message","I think Red is the imposter!"}};varmessageSendAck=awaitsocket.WriteChatMessageAsync(channelId,JsonWriter.ToJson(messageContent));varemoteContent=newDictionary<string,string>{{"emote","point"},{"emoteTarget","<RedPlayerUserId>"}};varemoteSendAck=awaitsocket.WriteChatMessageAsync(channelId,JsonWriter.ToJson(emoteContent));
Nakama는 메시지 업데이트 기능도 지원합니다. 이 기능은 원하는 경우 사용할 수 있지만 Sagi-shi와 같은 속임수 게임에서는 속임수가 추가할 수 있습니다.
예를 들어, 플레이어는 다음의 메시지를 전송합니다:
1
2
3
4
5
varchannelId="<ChannelId>"varmessageContent=newDictionary<string,string>{{"message","I think Red is the imposter!"}};varmessageSendAck=awaitsocket.WriteChatMessageAsync(channelId,JsonWriter.ToJson(messageContent));
다른 플레이어에게 혼동을 주기 위해서 메시지를 빠르게 편집할 수 있습니다:
1
2
3
4
varnewMessageContent=newDictionary<string,string>{{"message","I think BLUE is the imposter!"}};varmessageUpdateAck=awaitsocket.UpdateChatMessageAsync(channelId,messageSendAck.MessageId,JsonWriter.ToJson(newMessageContent));
Sagi-shi 플레이어는 대결을 생성하여 온라인 친구들이 참여하도록 초대할 수 있습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
varmatch=awaitsocket.CreateMatchAsync();varfriendsList=awaitclient.ListFriendsAsync(session,0,100);varonlineFriends=friendsList.Friends.Where(f=>f.User.Online).Select(f=>f.User);foreach(varfriendinonlineFriends){varcontent=new{message=string.Format("Hey {0}, join me for a match!",friend.Username),matchId=match.Id};varchannel=awaitsocket.JoinChatAsync(friend.Id,ChannelType.DirectMessage);varmessageAck=awaitsocket.WriteChatMessageAsync(channel,JsonWriter.ToJson(content));}
대결 이름으로 대결 생성하기
Sagi-shi 플레이어는 특정한 대결 이름을 사용하여 대결을 생성하여 친구에게 대결의 이름을 알려주고 초대할 수 있습니다. 이름으로 대결을 만들 때(임의의 이름이고 정식 대결 핸들러에 연결되지 않은 것) 대결은 항상 정식 대결이 아니라 중계된 대결이라는 점에 유의해야 합니다.
varmatchName="NoImpostersAllowed";// When joining by match name, you use the CreateMatchAsync function instead of the JoinMatchAsync functionvarmatch=awaitsocket.CreateMatchAsync(matchName);
플레이어 상태로 대결 참여하기
Sagi-shi 플레이어는 새로운 대결에 참여할 경우 상태를 업데이트할 수 있습니다:
1
2
3
4
5
6
7
varstatus=newDictionary<string,string>{{"Status","Playing a match"},{"MatchId","<MatchId>"}};awaitsocket.UpdateStatusAsync(JsonWriter.ToJson(status));
팔로워가 실시간 상태 이벤트를 수신한 경우, 대결에 참여할 수 있습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
socket.ReceivedStatusPresence+=asynce=>{// Join the first match found in a friend's statusforeach(varpresenceine.Joins){varstatus=JsonParser.FromJson<Dictionary<string,string>>(presence.Status);if(status.ContainsKey("MatchId")){awaitsocket.JoinMatchAsync(status["MatchId"]);break;}}};
varmatch=awaitsocket.JoinMatchAsync(matchId);varplayers=newDictionary<string,GameObject>();foreach(varpresenceinmatch.Presences){// Spawn a player for this presence and store it in a dictionary by session id.vargo=Instantiate(playerPrefab);players.Add(presence.SessionId,go);}
Sagi-shi는 대결 현재 상태 수신 이벤트를 사용하여 생성된 플레이어를 최신 상태로 유지합니다:
socket.ReceivedMatchPresence+=matchPresenceEvent=>{// For each player that has joined in this event...foreach(varpresenceinmatchPresenceEvent.Joins){// Spawn a player for this presence and store it in a dictionary by session id.vargo=Instantiate(playerPrefab);players.Add(presence.SessionId,go);}// For each player that has left in this event...foreach(varpresenceinmatchPresenceEvent.Leaves){// Remove the player from the game if they've been spawnedif(players.ContainsKey(presence.SessionId)){Destroy(players[presence.SessionId]);players.Remove(presence.SessionId);}}};
socket.ReceivedMatchState+=matchState=>{switch(matchState.OpCode){caseOpCodes.Position:// Get the updated position datavarstateJson=Encoding.UTF8.GetString(matchState.State);varpositionState=JsonParser.FromJson<PositionState>(stateJson);// Update the GameObject associated with that playerif(players.ContainsKey(matchState.UserPresence.SessionId)){// Here we would normally do something like smoothly interpolate to the new position, but for this example let's just set the position directly.players[matchState.UserPresence.SessionId].transform.position=newVector3(positionState.X,positionState.Y,positionState.Z);}break;default:Debug.Log("Unsupported op code");break;}};
개발자는 대결 목록 또는 Nakama Matchmaker를 사용하여 플레이어의 대결을 찾을 수 있습니다. 이를 통해 플레이어는 실시간 매치메이킹 풀에 참여하고 지정된 기준과 일치하는 다른 플레이어와 대결이 성사될 때 알림을 받을 수 있습니다.
매치메이킹을 통해 플레이어는 서로를 찾을 수 있지만 대결을 생성하지는 않습니다. 이러한 분리는 의도된 것이므로 게임 대결을 찾는 것 이상으로 매치메이킹을 사용할 수 있습니다. 예를 들어, 소셜 경험을 만들고 있는 경우 매치메이킹을 사용하여 채팅할 다른 사람을 찾을 수 있습니다.
Nakama는 알림을 구별하기 위해서 코드를 사용합니다. 0 이하의 코드는 Nakama 내부 직원을 위해 예약된 시스템입니다.
Sagi-shi 플레이어는 알림 수신 이벤트를 구독할 수 있습니다. Sagi-shi는 토너먼트 우승 100 코드를 사용합니다:
1
2
3
4
5
6
7
8
9
10
11
12
socket.ReceivedNotification+=notification=>{constintrewardCode=100;switch(notification.Code){caserewardCode:Debug.LogFormat("Congratulations, you won the tournament!\n{0}\n{1}",notification.Subject,notification.Content);break;default:Debug.LogFormat("Other notification: {0}:{1}\n{2}",notification.Code,notification.Subject,notification.Content);break;}};