分桶排行榜 #

如果还没有阅读排行榜文档,请阅读并熟悉相关的概念和功能后再继续。

随着游戏玩家群体的扩大,网络游戏中的社交互动出现了一个根本性问题。全局排行榜和一般排行榜逐渐形成静态局面,特定的技能或分数水平几乎没有变化。这会导致新玩家失去或缺乏参与度,从而对游戏失去兴趣。采用分桶排行榜可以解决这一问题。

在分桶排行榜中,玩家不会与所有其他玩家比赛,而是会看到有限的其他玩家(通常为25-50个)。他们与该群组竞争,直到排行榜到期或滚动到另一个开始时间。这些较小的玩家群体被称为队列或“玩家桶”,分桶排行榜的名称因此而产生。

在许多流行的游戏中都可以看到分桶排行榜,其中包括Rovio的Angry Birds系列中的几款游戏。 使用Nakama的存储引擎排行榜锦标赛功能,可以通过服务器运行时代码、特别是RPC函数在游戏中实现分桶排行榜。

Nakama排行榜API已经允许您输入一组用户ID(或用户名),这些ID将成为筛选器,用于生成排行榜的“分桶视图”,其中只有这些玩家。

这样就只剩下如何为特定玩组成用户ID集合,以及此集合是否被个性化或遵循其他游戏条件。这是成功实现的关键,它取决于游戏的特定机制。您可能希望“视图”显示排名相似的玩家、VIP玩家、用户的朋友或同一地区的玩家。

在本示例中,我们使用“随机”选择的用户作为特定玩家将要看到的分桶。我们首先浏览代码的关键部分,并在最后提供完整的文件供参考。

插件分桶排行榜 #

此处我们创建每周滚动的新的分桶排行榜Bucketed Weekly #1

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
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
  // Set up the bucketed leaderboards tournament
  id := "bucketed_weekly"
  authoritative := true
  sortOrder := "desc"
  operator := "incr"
  resetSchedule := "0 0 * * 0"
  metadata := map[string]interface{}{}
  title := "Bucketed Weekly #1"
  description := ""
  category := 1
  startTime := 0
  endTime := 0
  duration := 604800
  maxSize := 100000000
  maxNumScore := 100000000
  joinRequired := false

  if err := nk.TournamentCreate(ctx, id, authoritative, sortOrder, operator, resetSchedule, metadata, title, description, category, startTime, endTime, duration, maxSize, maxNumScore, joinRequired); err != nil {
    return err
  }
  if err := initializer.RegisterRpc("get_bucket_records", rpcGetBucketRecordsFn([]string{id}, 2000)); err != nil {
    return err
  }
  return nil
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  // Set up the bucketed leaderboards tournament
  const id = 'bucketed_weekly';
  const authoritative = true;
  const sortOrder = nkruntime.SortOrder.DESCENDING;
  const operator = nkruntime.Operator.INCREMENTAL;
  const duration = 604800;
  const resetSchedule = '0 0 * * 0';
  const metadata = {};
  const title = 'Bucketed Weekly #1';
  const description = '';
  const category = 1;
  const startTime = 0;
  const endTime = 0;
  const maxSize = 100000000;
  const maxNumScore = 10000000;
  const joinRequired = false;

  nk.tournamentCreate(id, authoritative, sortOrder, operator, duration, resetSchedule, metadata, title, description, category, startTime, endTime, maxSize, maxNumScore, joinRequired);
  initializer.registerRpc('get_bucket_records', RpcGetBucketRecords([id], 2000));
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

生成用户分桶 #

每个玩家都将有一个单独的分桶 — 他们与之对抗的对手组 — 这是单独为他们创建的。首先我们定义分桶存储对象:

Server
1
2
3
4
5
// Define the bucketed leaderboard storage object
type userBucketStorageObject struct {
  ResetTimeUnix uint32   `json:"resetTimeUnix"`
  UserIDs       []string `json:"userIds"`
}
Server
1
2
3
4
5
// Define the bucketed leaderboard storage object
interface UserBucketStorageObject {
  resetTimeUnix: number,
  userIds: string[]
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

然后我们定义一个RPC函数来获取玩家的ID并检查是否存在任何分桶。如找到分桶则获取它,如未找到则创建新的分桶。最后,我们获取实际排行榜。

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
// Get a user's bucket (records) and generate a new bucket if needed
func RpcGetBucketRecordsFn(ids []string, bucketSize int) func(context.Context, runtime.Logger, *sql.DB, runtime.NakamaModule, string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    if len(payload) > 0 {
      return "", ErrNoInputAllowed
    }

    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
      return "", ErrNoUserIdFound
    }

    collection := "buckets"
    key := "bucket"

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

    // Fetch any existing bucket or create one if none exist
    userBucket := &userBucketStorageObject{ResetTimeUnix: 0, UserIDs: []string{}}
    if len(objects) > 0 {
      if err := json.Unmarshal([]byte(objects[0].GetValue()), userBucket); err != nil {
        logger.Error("json.Unmarshal error: %v", err)
        return "", ErrUnmarshal
      }
    }

    // Fetch the leaderboard
    leaderboards, err := nk.LeaderboardsGetId(ctx, ids)
    if err != nil {
      logger.Error("nk.LeaderboardsGetId error: %v", err)
      return "", ErrInternalError
    }
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
const RpcGetBucketRecordsFn = function (ids: string[], bucketSize: number) {
  return function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
    if (!payload) {
      throw new Error('no payload input allowed');
    }

    const collection = 'buckets';
    const key = 'bucket';

    const objects = nk.storageRead([
      {
        collection,
        key,
        userId: ctx.userId
      }
    ]);

    // Fetch any existing bucket or create one if none exist
    let userBucket: UserBucketStorageObject = { resetTimeUnix: 0, userIds: [] };

    if (objects.length > 0) {
      userBucket = objects[0].value as UserBucketStorageObject;
    }

    // Fetch the tournament leaderboard
    const leaderboards = nk.tournamentsGetId(ids);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

检查分桶状态 #

在继续之前,我们会检查排行榜是否已重置或是否没有对手。如果是其中一种情况,必须生成一个新的对手组。

Server
1
2
3
4
5
6
// Leaderboard has reset or no current bucket exists for user
if userBucket.ResetTimeUnix != leaderboards[0].GetPrevReset() || len(userBucket.UserIDs) < 1 {
  logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

  // Code below goes here...
}
Server
1
2
3
4
5
6
// Leaderboard has reset or no current bucket exists for user
if (userBucket.resetTimeUnix != leaderboards[0].endActive || userBucket.userIds.length < 1) {
  logger.debug(`RpcGetBucketRecordsFn new bucket for ${ctx.userId}`);

  // Code below goes here
}
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

生成对手组 #

为生成随机对手列表,我们使用Nakama 3.5.0中的UsersGetRandom函数。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
userBucket.UserIDs = nil
logger.Debug("rpcGetBucketRecordsFn new bucket for %q", userID)

users, err := nk.UsersGetRandom(ctx, bucketSize)
if err != nil {
  logger.Error("Error getting random users.")
  return "", ErrInternalError
}

for _, user := range users {
  userBucket.UserIDs = append(userBucket.UserIDs, user.Id)
}
Server
1
2
3
4
const users = nk.usersGetRandom(bucketSize);
users.forEach(function (user: nkruntime.User) {
  userBucket.userIds.push(user.userId);
});
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

如果最终想要得到一个明确定义的对手列表(例如,仅限10-20级玩家),建议在数据库查询中按所需分桶大小的因子进行扫描,然后根据相关条件(玩家元数据)在应用层过滤器中进行扫描。

写入新分桶 #

在生成新的对手列表后,我们写入这个新的用户分桶,首先将分桶重置和排行榜结束时间设置到比赛。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Set the Reset and Bucket end times to be in sync
userBucket.ResetTimeUnix = leaderboards[0].GetNextReset()

value, err := json.Marshal(userBucket)
if err != nil {
  return "", ErrMarshal
}

// Store generated bucket for the user
if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{
  {
    Collection:      collection,
    Key:             key,
    PermissionRead:  0,
    PermissionWrite: 0,
    UserID:          userID,
    Value:           string(value),
  },
}); err != nil {
  logger.Error("nk.StorageWrite error: %v", err)
  return "", ErrInternalError
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Set the Reset and Bucket end times to be in sync
userBucket.resetTimeUnix = leaderboards[0].endActive;

// Store generated bucket for the user
nk.storageWrite([{
  collection,
  key,
  userId: ctx.userId,
  value: userBucket,
  permissionRead: 0,
  permissionWrite: 0
}]);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

最后,由于用户列表是伪随机生成的,因此用户本身可能会也可能不会被包括在其中,因此我们在列出记录之前,也会明确地将用户添加到分桶排行榜中。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Add self to the list of leaderboard records to fetch
userBucket.UserIDs = append(userBucket.UserIDs, userID)

_, records, _, _, err := nk.LeaderboardRecordsList(ctx, ids[0], userBucket.UserIDs, bucketSize, "", 0)
if err != nil {
  logger.Error("nk.LeaderboardRecordsList error: %v", err)
  return "", ErrInternalError
}

result := &api.LeaderboardRecordList{Records: records}
encoded, err := json.Marshal(result)
if err != nil {
  return "", ErrMarshal
}

logger.Debug("rpcGetBucketRecordsFn resp: %s", encoded)
return string(encoded), nil
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Add self to the list of leaderboard records to fetch
userBucket.userIds.push(ctx.userId);

// Get the leaderboard records
const records = nk.tournamentRecordsList(ids[0], userBucket.userIds, bucketSize);

const result = JSON.stringify(records);
logger.debug(`RpcGetBucketRecordsFn resp: ${result}`);

return JSON.stringify(records);
Code snippet for this language Lua has not been found. Please choose another language to show equivalent examples.

您可以使用Nakama控制台验证排行榜及其设置:

分桶排行榜
分桶排行榜

示例文件 #

下载完整实例文件bucketed_leaderboards.gobucketed_leaderboards.ts