正在创建财物 #

本指南将介绍如何使用Nakama的IAP验证虚拟钱包存储引擎功能开发游戏内的财物系统。

本指南将允许玩家通过IAP购买高级货币、宝石。然后可以在游戏中消耗这些宝石购买硬币,这些硬币可以购买游戏中的物品。

我们还将允许玩家使用IAP成为高级玩家,然后在必要时恢复购买。

使用IAP购买高级货币 #

以下服务器运行时代码示例假定使用Unity IAP包将购买收据提交到自定义Nakama RPC中。

此RPC首先检查有效负载,查看进行购买行为所使用的应用商店,然后使用适当的应用商店验证购买行为。如果购买行为有效,它会为阵列中的每个经过验证的购买行为调用一个单独的函数,以检查购买的产品ID,并在玩家的虚拟钱包中向玩家提供正确数量的宝石。

请注意,为了让Nakama验证购买,您必须提供适用于每个应用商店的配置变量

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
let RpcValidateIAP: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  // Assumes payload is a Unity IAP receipt
  const iap = JSON.parse(payload);

  // Validate the purchases depending on which app store was used
  let validatePurchaseResponse : nkruntime.ValidatePurchaseResponse;
  switch (iap.store) {
    case 'GooglePlay':
      validatePurchaseResponse = nk.purchaseValidateGoogle(ctx.userId, iap.payload);
      break;
    case 'AppleAppStore':
      validatePurchaseResponse = nk.purchaseValidateApple(ctx.userId, iap.payload);
      break;
    default:
      logger.warn('Unrecognised app store in payload')
      return JSON.stringify({ success: false });
      break;
  }

  validatePurchaseResponse.validatedPurchases.forEach(p => rewardPurchase(ctx, logger, nk, p));
  return JSON.stringify({ success: true });
};

let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  // Here we are just dealing with consumable IAPs
  switch (validatedPurchase.productId) {
    case 'gems_100':
      nk.walletUpdate(ctx.userId, { gems: 100 }, null, true);
      break;
    case 'gems_1000':
      nk.walletUpdate(ctx.userId, { gems: 1000 }, null, true);
      break;
  }
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
type IAPPayload struct {
	Store     string `json:"store"`
	ProductId int    `json:"productId"`
	Payload   string `json:"payload"`
}

func RpcValidateAPI(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Assumes payload is a Unity IAP receipt
	iap := &IAPPayload{}
	if err := json.Unmarshal([]byte(payload), iap); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("iap payload invalid", 3)
	}

	// Validate the purchases depending on which app store was used
	switch iap.Store {
	case "GooglePlay":
		validatePurchaseResponse, err := nk.PurchaseValidateGoogle(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Google")
			return "", runtime.NewError("error validated purchases with Google", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	case "AppleAppStore":
		validatePurchaseResponse, err := nk.PurchaseValidateApple(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Apple")
			return "", runtime.NewError("error validated purchases with Apple", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	default:
		logger.Warn("unrecognised app store in payload")
		return "", runtime.NewError("unrecognised app store", 13)
	}

	return "{}", nil
}

func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		// Here we are just dealing with consumable IAPs
		switch p.ProductId {
		case "gems_100":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 100,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		case "gems_1000":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 1000,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		}
	}
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

使用高级货币购买游戏内货币 #

玩家已购买高级货币,可以用高级货币购买游戏内的货币,硬币。以下RPC允许用户指定购买硬币所需要的宝石数量。这里的转换率(1宝石=1000硬币)采用硬编码。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let RpcPurchaseCoins: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  if (!request.gemsToSpend) {
    logger.warn('No gemsToSpend specified in RpcPurchaseCoins payload.');
    return JSON.stringify({ success: false, error: 'Failed to provide gems to spend amount.' });
  }

  // Check that the user has enough gems to spend
  const account = nk.accountGetId(ctx.userId);
  if (account.wallet['gems'] < request.gemsToSpend) {
    logger.warn('User does not have enough gems.');
    return JSON.stringify({ success: false, error: 'Not enough gems.' });
  }

  // Spend
  const coinsPerGem = 1000;
  nk.walletUpdate(ctx.userId, { coins: coinsPerGem * request.gemsToSpend, gems: -request.gemsToSpend });

  return JSON.stringify({ success: true });
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
type PurchaseCoinsPayload struct {
	GemsToSpend int `json:"gemsToSpend"`
}

func RpcPurchaseCoins(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseCoinsPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("purchase coins payload invalid", 3)
	}

	if request.GemsToSpend <= 0 {
		logger.Warn("no gemsToSpend specified in payload")
		return "", runtime.NewError("no gemsToSpend specified in payload", 3)
	}

	// Check that the user has enough gems to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data")
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet")
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["gems"] < request.GemsToSpend {
		logger.Warn("user does not have enough gems")
		return "", runtime.NewError("you do not have enough gems", 9)
	}

	// Spend
	coinsPerGem := 1000
	coins := coinsPerGem *  request.GemsToSpend
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(coins), "gems": int64(-request.GemsToSpend)}, nil, true)
	if err != nil {
		return "", runtime.NewError("unable to update wallet", 13)
	}

	return "{}", nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

使用游戏内货币购买物品 #

在本例中,我们将在Nakama存储引擎中存储一个配置对象,它将游戏中的每个项目映射到以硬币计算的价格。我们将在服务器的InitModule函数中进行此项配置。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const itemPrices = {
    'iron-sword': 100,
    'iron-shield': 150,
    'steel-sword': 500
  };

  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000', // Owned by the system user
    permissionRead: 2, // Public read
    permissionWrite: 0, // No write
    value: itemPrices
  };

  nk.storageWrite([ writeRequest ]);
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
itemPrices := map[string]int64 {
  "iron-sword": 100,
  "iron-shield": 150,
  "steel-sword": 500,
}

itemJson, err := json.Marshal(itemPrices)
if err != nil {
  logger.Error("error marshaling item prices")
  return runtime.NewError("error marshaling item prices", 13)
}

write := &runtime.StorageWrite{
  Collection: "configuration",
  Key: "prices",
  UserID: "00000000-0000-0000-0000-000000000000",
  PermissionRead: 2, // Public read
  PermissionWrite: 0, // No write
  Value: string(itemJson),
}

if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{write}); err != nil {
  logger.Error("error writing storage objects")
  return runtime.NewError("error writing storage objects", 13)
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

将我们的价格存储在存储引擎中,我们可以编写一个允许用户购买物品的RPC(前提是用户有足够的硬币)。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
let RpcPurchaseItem: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  // Make sure the user specified an item to buy
  if (!request.itemName) {
    logger.warn('No item name specified.');
    return JSON.stringify({ success: false, error: 'No item name specified.'});
  }

  // Lookup the item prices
  const readRequest: nkruntime.StorageReadRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000'
  };

  const readResult = nk.storageRead([readRequest]);
  if (readResult.length == 0)
  {
    logger.warn('No item prices in storage.');
    return JSON.stringify({ success: false, error: 'No item prices available.' });
  }
  
  const prices = readResult[0].value;

  // Check if there is a price for the requested item
  if (!prices[request.itemName]) {
    logger.warn(`No price available for ${request.itemName}`);
    return JSON.stringify({ success: false, error: `No price available for ${request.itemName}` });
  }

  // Check that the player has enough coins
  const account = nk.accountGetId(ctx.userId);

  if (account.wallet['coins'] < prices[request.itemName]) {
    logger.warn('Not enough coins to purchase item.');
    return JSON.stringify({ success: false, error: 'Not enough coins to purchase item.' });
  }

  // Decrease the player's coins
  nk.walletUpdate(ctx.userId, { coins: -prices[request.itemName] });

  // Get the player's current inventory.
  let inventory = {};

  const inventoryReadRequest: nkruntime.StorageReadRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId
  };

  const result = nk.storageRead([inventoryReadRequest]);
  if (result.length > 0) {
    inventory = result[0].value;
  }

  // Give the player the item (either increase quantity if they already possessed it or add one)
  if (inventory[request.itemName]) {
    inventory[request.itemName] += 1;
  } else {
    inventory[request.itemName] = 1;
  }

  // Define the storage write request to update the player's inventory.
  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId,
    permissionWrite: 1,
    permissionRead: 1,
    value: inventory
  };

  // Write the updated inventory to storage.
  const storageWriteAck = nk.storageWrite([writeRequest]);

  // Return an error if the write does not succeed.
  if (!storageWriteAck || storageWriteAck.length == 0) {
    return JSON.stringify({ success: false, error: 'Error saving inventory.' });
  }

  return JSON.stringify({ success: true });
};
Server
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
type PurchaseItemPayload struct {
	ItemName string `json:"itemName"`
}

func RpcPurchaseItem(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseItemPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload", err)
		return "", runtime.NewError("purchase item payload invalid", 3)
	}

	// Make sure the user specified an item to buy
	if request.ItemName == "" {
		logger.Warn("no item name specified")
		return "", runtime.NewError("no item name specified", 3)
	}

	// Lookup the item prices
	readRequest := &runtime.StorageRead{
		Collection: "configuration",
		Key:        "prices",
		UserID:     "00000000-0000-0000-0000-000000000000",
	}

	readResult, err := nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading item prices from storage", err)
		return "", runtime.NewError("error reading item prices from storage", 13)
	}

	if len(readResult) == 0 {
		logger.Warn("no item prices in storage")
		return "", runtime.NewError("no item prices in storage", 13)
	}

	// Check if there is a price for the requested item
	var prices map[string]int
	if err := json.Unmarshal([]byte(readResult[0].Value), &prices); err != nil {
		logger.Error("error unmarshaling prices", err)
		return "", runtime.NewError("error unmarshaling prices", 13)
	}

	if _, ok := prices[request.ItemName]; !ok {
		logger.Warn("no price available for %s", request.ItemName)
		return "", runtime.NewError(fmt.Sprintf("no price available for %s", request.ItemName), 5)
	}

	// Check that the player has enough coins to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data", err)
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet", err)
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["coins"] < prices[request.ItemName] {
		logger.Warn("not enough coins to purchase item")
		return "", runtime.NewError("not enough coins to purchase item", 9)
	}

	// Decrease the player's coins
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(-prices[request.ItemName])}, nil, true)
	if err != nil {
		logger.Error("unable to update wallet", err)
		return "", runtime.NewError("unable to update wallet", 13)
	}

	// Get the player's current inventory
	var inventory map[string]int

	readRequest = &runtime.StorageRead{
		Collection: "economy",
		Key:        "inventory",
		UserID:     userId,
	}

	readResult, err = nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading inventory from storage", err)
		return "", runtime.NewError("error reading inventory from storage", 13)
	}

	if len(readResult) > 0 {
		if err := json.Unmarshal([]byte(readResult[0].Value), &inventory); err != nil {
			logger.Error("error unmarshaling inventory", err)
			return "", runtime.NewError("error unmarshaling inventory", 13)
		}
	} else {
		inventory = make(map[string]int)
	}

	// Give the player the item (either increase quantity if they already possessed it or add one)
	if _, ok := inventory[request.ItemName]; ok {
		inventory[request.ItemName] += 1
	} else {
		inventory[request.ItemName] = 1
	}

	// Write the updated inventory to storage
	inventoryJson, err := json.Marshal(inventory)
	if err != nil {
		logger.Error("error marshaling inventory")
		return "", runtime.NewError("error marshaling inventory", 13)
	}

	writeRequest := &runtime.StorageWrite{
		Collection:      "economy",
		Key:             "inventory",
		UserID:          userId,
		PermissionRead:  1,
		PermissionWrite: 1,
		Value:           string(inventoryJson),
	}

	// Return an error if the write does not succeed
	storageWriteAck, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{writeRequest})
	if err != nil || len(storageWriteAck) == 0 {
		logger.Error("error saving inventory")
		return "", runtime.NewError("error saving inventory", 13)
	}

	return "{}", nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

使用IAP购买非消费品 #

除了能够通过应用程序内的购买行为购买虚拟货币之外,您还可能希望让玩家可以直接购买非消费品。

在这种情况下,我们将重新访问我们之前允许玩家购买宝石的服务器运行时RPC。但是,我们现在将添加购买非消费品的功能,以便稍后可以在其他设备上恢复这个功能。在这种情况下,通过购买非消费品,玩家将能够成为高级玩家。

为此,购买一经验证,我们将在用户的元数据中设置一个标志,表明他们是高级玩家。然后可以在整个游戏中凭借该标志提供各种被动技能/奖励。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  switch (validatedPurchase.productId) {
    // ...existing cases omitted
    case 'premium_status':
      const account = nk.accountGetId(ctx.userId);
      const metadata = account.user.metadata;
      metadata['premium'] = true;
      nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, metadata);
      break;
  }
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		switch p.ProductId {
		// ...existing cases omitted
		case "premium_status":
			account, err := nk.AccountGetId(ctx, userId)
			if err != nil {
				logger.Error("unable to get account", err)
				return
			}

			var metadata map[string]interface{}
			if err := json.Unmarshal([]byte(account.User.Metadata), &metadata); err != nil {
				logger.Error("error unmarshaling account metadata", err)
				return
			}

			metadata["premium"] = true
			nk.AccountUpdateId(ctx, userId, "", metadata, "", "", "", "", "")
			break
		}
	}
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

恢复IAP购买 #

如果用户更改了设备,应该可以恢复先前发生的所有购买行为。这不适用于在购买时已经“消费”的消费性购买行为(例如虚拟货币),但对于解锁完整游戏、移除广告或成为高级会员等事项,用户应该在安装您的游戏的任何新设备上享受所有相同的权益。

为此,我们将提供一个RPC,游戏客户端可以调用该RPC接收所有已在Nakama中验证为成功购买的IAP产品ID和购买时间戳的列表。之后,客户端可以使用此信息在新设备上恢复适当的功能。

Server
1
2
3
4
5
6
7
8
9
let RpcRestorePurchases: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const purchases = nk.purchasesList(ctx.userId);

  const response = {
    purchases = purchases.validatedPurchases.map(v => { v.productId, v.purchaseTime })
  };

  return JSON.stringify(response);
};
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
type RestorePurchasesResponse struct {
	Purchases []*ValidatedPurchaseItem `json:"purchases"`
}

type ValidatedPurchaseItem struct {
	ProductId    string `json:"productId"`
	PurchaseTime int64  `json:"purchaseTime"`
}

func RpcRestorePurchases(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Get the list of validated purchases
	purchases, err := nk.PurchasesList(ctx, userId, 100, "")
	if err != nil {
		logger.Error("error retrieving purchases", err)
		return "", runtime.NewError("error retrieving purchases", 13)
	}

	// Construct the response
	purchasesResponse := &RestorePurchasesResponse{
		Purchases: []*ValidatedPurchaseItem{},
	}

	for _, purchase := range purchases.ValidatedPurchases {
		purchasesResponse.Purchases = append(purchasesResponse.Purchases, &ValidatedPurchaseItem{ProductId: purchase.ProductId, PurchaseTime: purchase.PurchaseTime.Seconds})
	}

	// Marshal the response
	jsonResponse, err := json.Marshal(purchasesResponse)
	if err != nil {
		logger.Error("error marshaling response", err)
		return "", runtime.NewError("error marshaling response", 13)
	}

	return string(jsonResponse), nil
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.