저장소 #

대부분의 게임의 경우, 중요한 플레이어 데이터(예: 인벤토리 또는 게임 통계)를 계속 추적해야 합니다. 싱글플레이어 게임의 경우, 클라이언트에 로컬로 정보를 저장하면 되지만, 멀티플레이어 게임에서는 데이터를 동시에 여러 개의 클라이언트로 전송하거나 변경되지 않도록 보호해야 합니다.

Nakama 저장소 엔진을 통해 서버에서 사용자 정보를 안전하게 저장하고 액세스할 수 있습니다.

이 섹션에서는 아래 항목에 대해서 살펴봅니다:

  • 서버 데이터베이스에서 읽기 및 쓰기
  • 가상 화폐를 보유하기 위한 지갑 생성
  • 지갑 장부를 통해 트랜잭션 관리
  • 지갑 내에 사용자 지정 메타 데이터 포함

모음 #

Nakama에서 사용자 지정 데이터는 특정 사용자에 대한 데이터를 보유하는 objects의 그룹을 포함하는 collections(으)로 구성됩니다. 이 구조를 통해 유사한 정보를 저장하고 쉽게 액세스할 수 있습니다.

Pirate Panic의 경우, 플레이어는 대결에서 사용할 카드 덱을 들고 있습니다:

덱 탐색기
덱 탐색기

덱의 상태를 서버에 저장하여 사용자가 게임을 종료했다가 다시 참여해도 덱의 정보가 남아있도록 합니다.

서버에서 읽기 및 쓰기 #

먼저, 각 플레이어가 시작할 카드를 설정합니다:

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let deck: any = {};

const DefaultDeckCards = [
  {
    type: 1,
    level: 1,
  },
  ... // more cards here
]

DefaultDeckCards.forEach(c => {
  deck[nk.uuidv4()] = c;
});

여기서, 기존의 덱 데이터는 개체 문자 배열로 표시됩니다. 각각의 개체에는 카드 유형과 카드가 업그레이드된 수준을 표시하는 두 개의 숫자 속성이 포함됩니다.

카드의 유형과 수준은 Pirate Panic에만 표시되기 때문에 게임에서는 자체적으로 사용자 지정 정보가 포함될 확률이 높습니다. 예를 들어, 각각의 개체에는 취득한 날짜, 특별한 변이, 플레이어가 알아야 할 특이사항 등이 포함될 수 있습니다.

그런 다음, uuidv4() 함수를 사용하여 고유한 ID를 부여하고, 데이터를 저장소 개체로 변환해야 합니다. 이 작업은 동일한 카드의 상태를 추적할 때 필요합니다.

이 덱이 있는 경우, storageWrite 함수를 사용하여 저장할 수 있습니다:

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
nk.storageWrite([
  {
    key: "card_collection",
    collection: "user_cards",
    userId: userId,
    value: deck,
    permissionRead: 2,
    permissionWrite: 0,
  }
]);

다음 구조를 사용하는 집합을 생성합니다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
card_collection
 user_cards: {
    userId: some-user-id-3fb1d6, // some arbitrary ID
    permissionRead: 2,
    permissionWrite: 2,
    ... // some other properties
    value: {
      some-random-id: { // another arbitrary ID
        type: 1,
        level: 1,
      }
      ... // more cards
    }
 }

나중에 이 카드에 액세스하려면 요청 개체를 사용하여 storageRead(을)를 호출할 수 있습니다:

deck.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function loadUserCards(nk: nkruntime.Nakama, logger: nkruntime.Logger, userId: string): CardCollection {
  let storageReadReq: nkruntime.StorageReadRequest = {
    key: DeckCollectionKey,
    collection: DeckCollectionName,
    userId: userId,
  }

  let objects: nkruntime.StorageObject[] = nk.storageRead([storageReadReq]);

  // Get the original collection for processing
  let storedCardCollection = objects[0].value;
  ...
}

단일 storageRead 호출을 사용하여 여러 StorageReadRequest을(를) storageRead의 배열에 추가하고, 여러 요청 사항을 동시에 생성할 수 있습니다.

클라이언트에서 읽기 및 쓰기 #

Unity 클라이언트에서 집합과 인터페이스를 적용하기 위해서 친구 찾기에서와 동일하게 RPC를 사용합니다. 이렇게 하면 사용자의 요청 사항 유형과 모든 민감한 작업이 서버에서만 진행되도록 제어할 수 있습니다.

로컬 플레이어가 함수 ReadStorageObjectsAsync을(를) 직접 사용하여 데이터를 읽을 수 있습니다:

ProfilePanel.cs

1
2
3
4
5
6
StorageObjectId personalStorageId = new StorageObjectId();
personalStorageId.Collection = "personal";
personalStorageId.UserId = _connection.Session.UserId;
personalStorageId.Key = "player_data";

IApiStorageObjects personalStorageObjects = await _connection.Client.ReadStorageObjectsAsync(_connection.Session, personalStorageId);

여기서는, 찾고자 하는 개체를 식별할 때 사용하는 StorageObjectId struct(으)로 Collection, UserId, Key 속성을 전달합니다.

권한 추가 #

Nakama는 민감한 데이터에 승인되지 않은 액세스를 방지하기 위해서 데이터베이스에서 읽기 및 쓰기 권한 처리를 지원합니다.

세 가지 permissionRead 수준을 통해 개체 읽기를 보호할 수 있습니다:

  • 0: 아무도 개체를 읽을 수 없습니다(서버 제외).
  • 1: 해당 개체(userId 대결)를 보유한 사용자만 읽을 수 있습니다.
  • 2: 아무나 이 개체를 읽을 수 있습니다.

쓰기에는 두 가지 수준이 있습니다. 0은(는) 쓰기가 허용되지 않으며, 1은(는) 소유자에 대해서만 쓰기만 허용됩니다. 쓰기의 경우, 서버는 권한을 건너뛰고 모든 개체를 작성할 수 있습니다. 사용자는 수준 0을(를) 사용하여 데이터를 직접 편집하지 않고 서버에서 쓰기 요청 사항을 확인합니다.

예를 들어, 사용자 통계를 모든 사람이 볼 수 있도록 하려고 하지만, 서버에서만 변경할 수 있습니다:

1
2
3
4
5
6
7
8
const writeStats: nkruntime.StorageWriteRequest = {
  collection: "stats",
  key: "public",
  permissionRead: 2,
  permissionWrite: 0,
  value: initialState,
  userId: ctx.userId,
}

조건부 읽기 및 쓰기 #

여러 사용자가 동일한 개체에 액세스(쓰기 포함)가 필요할 수 있습니다.

수정 사항이 충돌하지 않도록 version 속성을 모든 쓰기 요청으로 전달할 수 있습니다. 서버에서 writeStorageObjects을(를) 사용하거나 클라이언트에서 WriteStorageObjectsAsync을(를) 사용하면, 입력된 버전이 데이터베이스에 저장된 버전과 일치할 때에만 쓰기 작업을 할 수 있습니다.

이 기능은 Pirate Panic에 표시되지 않지만, 조건부 저장에 대해서 배우고 게임 내에서 실행할 수 있습니다.

지갑 #

일반적인 집합 외에 Nakama에는 지갑 기능이 포함되어 게임 내 화폐로 트랜잭션을 하거나 저장할 수 있도록 지원합니다.

Pirate Panic의 경우, 보석을 화폐로 사용하기 때문에 새로운 카드를 구입할 때 보석을 사용할 수 있습니다.

보석!
보석!

지갑 수량 업데이트 #

집합과 달리, 지갑은 서버에서 직접 업데이트하여 사용자가 원하지 않거나 착취하는 형태의 트랜잭션이 발생하지 않도록 합니다.

네 개의 매개 변수를 사용하는 walletUpdate을(를) 통해 서버 측에서 지갑의 값을 변경할 수 있습니다.

  • 변경할 지갑의 사용자 ID
  • 업데이트할 수량과 모든 화폐 이름이 일치하는 키 값 쌍을 포함하는 개체
  • 메타데이터 개체
  • 장부 업데이트가 필요한 경우 서버에 알림을 제공하는 부울

Pirate Panic에는 한 개의 화폐만 있기 때문에 보석이 추가될 때마다 changeset을(를) 조정하지 않고 도움말 함수를 작성하여 숫자를 전달하면 됩니다:

economy.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const currencyKeyName = "gems" // Can be changed to whatever you want!
...
function updateWallet(nk: nkruntime.Nakama, userId: string, amount: number, metadata: {[key: string]: any}): nkruntime.WalletUpdateResult {
  const changeset = {
    [currencyKeyName]: amount,
  }
  let result = nk.walletUpdate(userId, changeset, metadata, true);

  return result;
}

amount은(는) 현재 지갑에서 잔고를 변경할 양이고, 현재 잔고를 완전히 대체하지 않는다는 것을 유의합니다. 예를 들어, 지갑에 보석이 900개 있고 updateWallet에 100개가 호출된 경우, 새로운 잔고는 1,000(100이 아님)이 됩니다.

이 항목을 RPC에 적용하여 특정한 상황에서 Unity 클라이언트가 보석을 추가할 수 있도록 합니다. 예를 들어, 무료로 보석을 제공하는 버튼을 만들려고 합니다:

RPC는 다음과 같이 표시됩니다:

1
2
3
4
5
6
7
8
const rpcAddUserGems: nkruntime.RpcFunction = function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama): string {
  let walletUpdateResult = updateWallet(nk, ctx.userId, 100, {});
  let updateString = JSON.stringify(walletUpdateResult);

  logger.debug("Added 100 gems to user %s wallet: %s", ctx.userId, updateString);

  return updateString;
}

Unity에서 호출하려면 다음과 같이 합니다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void Awake() {
    ...
    _getFreeGemsButton.onClick.AddListener(HandleAddFreeGems); // Bind function to button
}
...
private async void HandleAddFreeGems() {
    // Call the RPC we just made!
    IApiRpc newGems = await _connection.Client.RpcAsync(_connection.Session, "add_user_gems");
    // IMPORTANT: Update the account instance to get the latest results!
    _connection.Account = await _connection.Client.GetAccountAsync(_connection.Session);
    ...
}

음의 수량을 전달하여 플레이어의 지갑에서 보석을 차감할 수도 있습니다. 예를 들어, 100개의 보석을 사용하여 카드를 구매하려면 updateWallet(nk, ctx.userId, -100, {});을(를) 호출합니다.

사용자 지정 메타데이터 #

메타데이터 객체를 사용하면 모든 사용자 지정 정보를 지갑 트랜잭션에 입력할 수 있습니다. walletLedgerList에 있는 모든 항목으로 정보가 전달되어 나중에 참조할 수 있습니다.

예를 들어, 대결 ID에 보상을 연결하여 같은 장소에서 보상이 제공되고 플레이어에 대한 전체 보상을 볼 수 있도록 하려면 다음과 같이 합니다:

1
2
3
4
5
6
let metadata = {
  source: "match_reward",
  match_id: request.matchId,
};

updateWallet(nk, ctx.userId, score, metadata);

지갑 수량 가져오기 #

클라이언트 측의 지갑 정보는 인증 설정 시 사용한 연결 정보를 보유하는 GameConnection 클래스에서 connection.Account이(가) IApiAccount 인스턴스인 connection.Account.Wallet에 저장됩니다.

문자열로 전환된 JSON 개체 형식으로 제공되는 Wallet 변수를 얻으면, 다음 코드를 사용하여 지갑에서 많은 보석을 추출할 수 있습니다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private int GetGems(string wallet)
{
    Dictionary<string, int> currency = wallet.FromJson<Dictionary<string, int>>();

    if (currency.ContainsKey("gems"))
    {
        return currency["gems"];
    }

    return 0;
}

Pirate Panic의 경우, GameConnection을(를) 생성했기 때문에 다음을 사용하여 GetGems을(를) 호출할 수 있습니다:

1
GetGems(_connection.Account.Wallet);

트랜잭션 장부 #

위에서 true로 설정한 updateLedger에 한 개의 최종 매개 변수가 있습니다.

true(으)로 설정된 updateLedger와(과) 함께 walletUpdate이(가) 호출되면 새로운 장부 항목이 생성됩니다. 이 작업을 통해 이전 트랜잭션과 세부내역을 살펴볼 수 있습니다.

과거부터 가장 최근까지 정렬된 모든 트랜잭션의 목록을 보려면 walletLedgerList을(를) 호출합니다.

그런 다음, cursor을(를) 사용하여 원하는 항목으로 이동할 수 있습니다. 예를 들어, 가장 최근의 커서 업데이트를 보려면 다음과 같이 합니다:

match.ts

1
2
3
4
let items = nk.walletLedgerList(ctx.userId, 100);
while (items.cursor) {
  items = nk.walletLedgerList(ctx.userId, 100, items.cursor);
}

이 작업은 현재 대결의 보상으로 이동할 때 유용합니다. 최초 보상에 커서 업데이트가 되도록 설정하고 커서 업데이트를 사용하지 않는 상태에서 증분 업데이트(예: 타워 캡처)를 지속하면 이 전략을 사용하여 가장 최신의 대결에서 모든 트랜잭션의 목록을 가져올 수 있습니다.

다음 주제 #

매치메이킹

Related Pages