每日奖励 #

吸引和留住玩家的一种常见方式是为他们每天登录游戏提供每日奖励。

在本例中,您将学习如何使用服务器运行时代码实现每日奖励系统。

前提条件 #

为轻松学习本教程,请执行以下操作后再继续:

注册RPC #

每日奖励示例定义了两个RPC(远程过程调用)来检查资格并发放奖励。

然后RPC在main.tsmain.gomain.lua中注册到Nakama事件。

Server
1
2
3
4
5
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
    initializer.registerRpc('canclaimdailyreward_js', rpcCanClaimDailyReward);
    initializer.registerRpc('claimdailyreward_js', rpcClaimDailyReward);
    logger.info('JavaScript logic loaded.');
}
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
package main

import (
    "context"
    "database/sql"
    "time"

    "github.com/heroiclabs/nakama-common/runtime"
    "github.com/heroiclabs/unity-devrel-samples/modules"
)

const (
    rpcIdCanClaimDailyReward = "canclaimdailyreward_go"
    rpcIdClaimDailyReward    = "claimdailyreward_go"
)

//noinspection GoUnusedExportedFunction
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
    initStart := time.Now()

    if err := initializer.RegisterRpc(rpcIdCanClaimDailyReward, modules.RpcCanClaimDailyReward); err != nil {
        return err
    }

    if err := initializer.RegisterRpc(rpcIdClaimDailyReward, modules.RpcClaimDailyReward); err != nil {
        return err
    }

    logger.Info("Plugin loaded in '%d' msec.", time.Since(initStart).Milliseconds())
    return nil
}
Server
1
2
3
4
5
local nk = require("nakama")
local daily_reward = require("daily_reward")

nk.register_rpc(daily_reward.rpc_can_claim_daily_reward, "canclaimdailyreward_lua")
nk.register_rpc(daily_reward.rpc_claim_daily_reward, "claimdailyreward_lua")

要向服务器注册RPC,需要指定字符串标识符以及客户端调用RPC时要运行的函数。

实现RPC #

RPC实现以下逻辑:

canClaimDailyReward #

  • Nakama存储引擎获取最新每日奖励对象。
  • 检查用户上次领取奖励的时间是否在00:00之前
  • 返回JSON响应,指示用户是否可以领取每日奖励

claimDailyReward #

  • Nakama存储引擎获取最新每日奖励对象。
  • 检查用户上次领取奖励的时间是否在00:00之前
  • 更新用户的钱包
  • 向用户发送通知
  • 更新或在Nakama存储引擎中创建最新每日奖励对象
  • 返回一个包含收到硬币数量的JSON响应

模块代码 #

本节内容专门面向Go或Lua用户。您需要在日常奖励模块脚本中为每种语言添加一些额外的代码。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package modules

import (
    "context"
    "database/sql"
    "encoding/json"
    "time"

    "github.com/heroiclabs/nakama-common/api"
    "github.com/heroiclabs/nakama-common/runtime"
)

var (
    errInternalError  = runtime.NewError("internal server error", 13) // INTERNAL
    errMarshal        = runtime.NewError("cannot marshal type", 13)   // INTERNAL
    errNoInputAllowed = runtime.NewError("no input allowed", 3)       // INVALID_ARGUMENT
    errNoUserIdFound  = runtime.NewError("no user ID in context", 3)  // INVALID_ARGUMENT
    errUnmarshal      = runtime.NewError("cannot unmarshal type", 13) // INTERNAL
)
Server
1
2
3
4
5
6
7
8
local nk = require("nakama")

local M = {
}

-- Module code goes here

return M

获取上一个每日奖励对象 #

我们来看从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
35
function getLastDailyRewardObject(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) : any {
    if (!context.userId) {
        throw Error('No user ID in context');
    }

    if (payload) {
        throw Error('No input allowed');
    }

    var objectId: nkruntime.StorageReadRequest = {
        collection: 'reward',
        key: 'daily',
        userId: context.userId,
    }

    var objects: nkruntime.StorageObject[];
    try {
        objects = nk.storageRead([ objectId ]);
    } catch (error) {
        logger.error('storageRead error: %s', error);
        throw error;
    }

    var dailyReward: any = {
        lastClaimUnix: 0,
    }

    objects.forEach(function (object) {
        if (object.key == 'daily') {
            dailyReward = object.value;
        }
    });

    return dailyReward;
}
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
type dailyReward struct {
    LastClaimUnix int64 `json:"last_claim_unix"` // The last time the user claimed the reward in UNIX time.
}

func getLastDailyRewardObject(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, payload string) (dailyReward, *api.StorageObject, error) {
    var d dailyReward
    d.LastClaimUnix = 0

    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return d, nil, errNoUserIdFound
    }

    if len(payload) > 0 {
        return d, nil, errNoInputAllowed
    }

    objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
        Collection: "reward",
        Key:        "daily",
        UserID:     userID,
    }})
    if err != nil {
        logger.Error("StorageRead error: %v", err)
        return d, nil, errInternalError
    }

    var o *api.StorageObject
    for _, object := range objects {
        switch object.GetKey() {
        case "daily":
            if err := json.Unmarshal([]byte(object.GetValue()), &d); err != nil {
                logger.Error("Unmarshal error: %v", err)
                return d, nil, errUnmarshal
            }
            return d, object, nil
        }
    }

    return d, o, nil
}
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
function get_last_daily_reward_object(context, payload)
    if (not context.user_id or #context.user_id < 1) then
        error({ "no user ID in context", 3 })
    end

    if (#payload > 0) then
        error({ "no input allowed", 3 })
    end

    local objectid = {
        collection = "reward",
        key = "daily",
        user_id = context.user_id
    }
    local success, objects = pcall(nk.storage_read, { objectid })
    if (not success) then
        nk.logger_error(string.format("storage_read error: %q", objects))
        error({ "internal server error", 13 })
    end

    local daily_reward = {
        ["last_claim_unix"] = 0
    }
    for _, object in ipairs(objects)
    do
        if (object.key == "daily") then
            daily_reward = object.value
            break
        end
    end

    return daily_reward
end

无论您使用哪种语言,核心逻辑都是相同的。

  • 检查上下文以确保存在有效的用户ID
  • 检查用户未在有效负载中传递任何内容
  • 从存储引擎查询reward集合中的daily对象
  • 返回每日奖励对象或默认对象

检查用户是否有资格获得每日奖励 #

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function canUserClaimDailyReward(dailyReward: any) {
    if (!dailyReward.lastClaimUnix) {
        dailyReward.lastClaimUnix = 0;
    }

    var d = new Date();
    d.setHours(0, 0, 0, 0);

    return dailyReward.lastClaimUnix < msecToSec(d.getTime());
}

function msecToSec(n: number): number {
    return Math.floor(n / 1000);
}
Server
1
2
3
4
5
func canUserClaimDailyReward(d dailyReward) bool {
    t := time.Now()
    midnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
    return time.Unix(d.LastClaimUnix, 0).Before(midnight)
}
Server
1
2
3
4
5
function can_user_claim_daily_reward(daily_reward)
    local dt = os.date("*t")
    local elapsed_sec_from_midnight = (dt.hour * 3600 + dt.min * 60 + dt.sec) % 86400
    return daily_reward.last_claim_unix < (os.time() - elapsed_sec_from_midnight)
end

此函数检查每日奖励对象的上次领奖的Unix时间戳值。如果小于前一天午夜的时间戳,则返回true,否则返回false。

CanClaimDailyReward RPC #

两个助手函数完成后即可实现第一个RPC。此RPC将返回检查用户是否符合JSON对象资格的助手函数的值。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function rpcCanClaimDailyReward(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
    var dailyReward = getLastDailyRewardObject(context, logger, nk, payload);
    var response = {
        canClaimDailyReward: canUserClaimDailyReward(dailyReward)
    }

    var result = JSON.stringify(response);
    logger.debug('rpcCanClaimDailyReward response: %q', result);

    return result;
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func RpcCanClaimDailyReward(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    var resp struct {
        CanClaimDailyReward bool `json:"canClaimDailyReward"`
    }

    dailyReward, _, err := getLastDailyRewardObject(ctx, logger, nk, payload)
    if err != nil {
        logger.Error("Error getting daily reward: %v", err)
        return "", errInternalError
    }

    resp.CanClaimDailyReward = canUserClaimDailyReward(dailyReward)

    out, err := json.Marshal(resp)
    if err != nil {
        logger.Error("Marshal error: %v", err)
        return "", errMarshal
    }

    logger.Debug("rpcCanClaimDailyReward resp: %v", string(out))
    return string(out), nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function M.rpc_can_claim_daily_reward(context, payload)
    local daily_reward = get_last_daily_reward_object(context, payload)
    local resp = {
        ["canClaimDailyReward"] = can_user_claim_daily_reward(daily_reward)
    }

    local success, result = pcall(nk.json_encode, resp)
    if (not success) then
        nk.logger_error(string.format("json_encode error: %q", result))
        error({ "internal server error", 13 })
    end

    nk.logger_debug(string.format("rpc_can_claim_daily_reward resp: %q", result))
    return result
end

ClaimDailyReward RPC #

此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
function rpcClaimDailyReward(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
    var response = { coinsReceived: 0 };

    var dailyReward = getLastDailyRewardObject(context, logger, nk, payload);
    if (canUserClaimDailyReward(dailyReward)) {
        response.coinsReceived = 500;

        var changeset = {
            coins: response.coinsReceived,
        }

        try {
            nk.walletUpdate(context.userId, changeset, {}, false);
        } catch (error) {
            logger.error('walletUpdate error: %q', error);
            throw error;
        }

        var notification: nkruntime.NotificationRequest = {
            code: 1001,
            content: changeset,
            persistent: true,
            subject: "You've received your daily reward!",
            userId: context.userId,
        }

        try {
            nk.notificationsSend([notification]);
        } catch (error) {
            logger.error('notificationsSend error: %q', error);
            throw error;
        }

        dailyReward.lastClaimUnix = msecToSec(Date.now());

        var write: nkruntime.StorageWriteRequest = {
            collection: 'reward',
            key: 'daily',
            permissionRead: 1,
            permissionWrite: 0,
            value: dailyReward,
            userId: context.userId,
        }

        if (dailyReward.version) {
            // Use OCC to prevent concurrent writes.
            write.version = dailyReward.version
        }

        // Update daily reward storage object for user.
        try {
            nk.storageWrite([ write ])
        } catch (error) {
            logger.error('storageWrite error: %q', error);
            throw error;
        }
    }

    var result = JSON.stringify(response);
    logger.debug('rpcClaimDailyReward response: %q', result)

    return result;
}
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
func RpcClaimDailyReward(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return "", errNoUserIdFound
    }

    var resp struct {
        CoinsReceived int64 `json:"coinsReceived"`
    }
    resp.CoinsReceived = int64(0)

    dailyReward, dailyRewardObject, err := getLastDailyRewardObject(ctx, logger, nk, payload)
    if err != nil {
        logger.Error("Error getting daily reward: %v", err)
        return "", errInternalError
    }

    if canUserClaimDailyReward(dailyReward) {
        resp.CoinsReceived = 500

        // Update player wallet.
        changeset := map[string]int64{
            "coins": resp.CoinsReceived,
        }
        if _, _, err := nk.WalletUpdate(ctx, userID, changeset, map[string]interface{}{}, false); err != nil {
            logger.Error("WalletUpdate error: %v", err)
            return "", errInternalError
        }

        err := nk.NotificationsSend(ctx, []*runtime.NotificationSend{{
            Code: 1001,
            Content: map[string]interface{}{
                "coins": changeset["coins"],
            },
            Persistent: true,
            Sender:     "", // Server sent.
            Subject:    "You've received your daily reward!",
            UserID:     userID,
        }})
        if err != nil {
            logger.Error("NotificationsSend error: %v", err)
            return "", errInternalError
        }

        dailyReward.LastClaimUnix = time.Now().Unix()

        object, err := json.Marshal(dailyReward)
        if err != nil {
            logger.Error("Marshal error: %v", err)
            return "", errInternalError
        }

        version := ""
        if dailyRewardObject != nil {
            // Use OCC to prevent concurrent writes.
            version = dailyRewardObject.GetVersion()
        }

        // Update daily reward storage object for user.
        _, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
            Collection:      "reward",
            Key:             "daily",
            PermissionRead:  1,
            PermissionWrite: 0, // No client write.
            Value:           string(object),
            Version:         version,
            UserID:          userID,
        }})
        if err != nil {
            logger.Error("StorageWrite error: %v", err)
            return "", errInternalError
        }
    }

    out, err := json.Marshal(resp)
    if err != nil {
        logger.Error("Marshal error: %v", err)
        return "", errMarshal
    }

    logger.Debug("rpcClaimDailyReward resp: %v", string(out))
    return string(out), nil
}
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
function M.rpc_claim_daily_reward(context, payload)
    local resp = {
        ["coinsReceived"] = 0
    }

    local daily_reward = get_last_daily_reward_object(context, payload)

    -- If last claimed is before the new day grant a new reward!
    if (can_user_claim_daily_reward(daily_reward)) then
        resp.coinsReceived = 500

        -- Update player wallet.
        local changeset = {
            ["coins"] = resp.coinsReceived
        }
        local success, result = pcall(nk.wallet_update, context.user_id, changeset, {}, false)
        if (not success) then
            nk.logger_error(string.format("wallet_update error: %q", result))
            error({ "internal server error", 13 })
        end

        local notification = {
            code = 1001,
            content = changeset,
            persistent = true,
            sender = "",
            subject = "You've received your daily reward!",
            user_id = context.user_id
        }
        local success, result = pcall(nk.notifications_send, { notification })
        if (not success) then
            nk.logger_error(string.format("notifications_send error: %q", result))
            error({ "internal server error", 13 })
        end

        daily_reward.last_claim_unix = os.time()

        local version = nil
        if (daily_reward.version) then
            -- Use OCC to prevent concurrent writes.
            version = daily_reward.version
        end

        -- Update daily reward storage object for user.
        local write = {
            collection = "reward",
            key = "daily",
            permission_read = 1,
            permission_write = 0,
            value = daily_reward,
            version = version,
            user_id = context.user_id
        }
        local success, result = pcall(nk.storage_write, { write })
        if (not success) then
            nk.logger_error(string.format("storage_write error: %q", result))
            error({ "internal server error", 13 })
        end
    end

    local success, result = pcall(nk.json_encode, resp)
    if (not success) then
        nk.logger_error(string.format("json_encode error: %q", result))
        error({ "internal server error", 13 })
    end

    nk.logger_debug(string.format("rpc_claim_daily_reward resp: %q", result))
    return result
end

了解Nakama控制台 #

启动服务器(使用Docker)并使用Nakama控制台测试RPC。

打开浏览器,进入http://localhost:7351,可访问Nakama控制台。

进入后,您可以尝试通过API资源管理器与RPC交互,方法是从下拉列表中选择它们并指定用户ID作为上下文。

总结 #

实现了这两个RPC,您现在有了一个简单的每日奖励系统,恭喜!

您可以通过添加更复杂的资格条件或其他此类功能来进一步尝试。