WWDC Quick Look 💓 By SwiftGGTeam
Bring Recurring Leaderboards to your game

Bring Recurring Leaderboards to your game

观看原视频

Highlight

iOS 15 引入循环排行榜(Recurring Leaderboards),按开发者定义的周期(每小时、每天、每周)自动重置,玩家可查看当前周期和历史周期的排名,系统自动管理 occurrence 生命周期。

核心内容

传统排行榜是永久的,新玩家永远追不上早期玩家。游戏只能自己做临时活动排行榜,逻辑复杂且需要服务端支持。

iOS 15 的循环排行榜让系统帮你做这件事。在 App Store Connect 中配置一次,系统按固定周期创建新的 occurrence。比如每日排行榜:每天零点自动开启新一轮,前一天的数据归档,玩家仍可查看 30 天内的历史排名。

这解决了几个实际问题:

  • 新玩家每个周期都有公平竞争机会
  • 做定期活动不需要自己开发排行榜
  • 玩家可以看到自己跨周期的进步或退步

在 App Store Connect 中配置

06:59

进入 App Store Connect → 你的 App → Features → Game Center → Leaderboards → 点击「+」 → 选择 Recurring Leaderboard。

基础配置字段:

字段说明示例
Reference NameApp Store Connect 内部使用的名称DailyHighScore
Leaderboard ID代码中引用的 IDdaily_high_score
Score Format Type分数显示格式Integer、Time、Money
Score Submission Type多次提交时取哪个分数Best(最高分)或 Most Recent
Sort Order排序方式High to Low(高分在前)
Score Range有效分数范围最小值 0,最大值 200

循环排行榜专属字段:

字段说明
Start Date and Time第一个 occurrence 的开始时间(UTC)
Duration单个 occurrence 的持续时间
Restarts Every新 occurrence 的创建频率

关键点:

  • Restarts Every 必须 ≥ Duration,确保 occurrence 不重叠
  • 如果希望无间隔连续循环,设置 Restarts Every = Duration
  • 不能选择过去的时间作为开始时间

本地化配置:

至少配置一种语言的信息:

  • Display Name:在 Game Center UI 中显示的名称
  • Score Format:千分位分隔符(逗号或句点)
  • Score Format Suffix:分数单位(如 “points”)
  • Leaderboard Image:可选但推荐上传,提升品牌识别度

认证本地玩家

10:35

在使用 Game Center 任何功能之前,需要认证本地玩家:

func authenticateLocalPlayer() {
    GKLocalPlayer.local.authenticateHandler = { viewController, error in
        if let viewController = viewController {
            // 需要显示登录界面
            self.present(viewController, animated: true)
        } else if GKLocalPlayer.local.isAuthenticated {
            // 认证成功
            print("Authenticated successfully")
        } else {
            // 认证失败
            print("Authentication failed: \(error?.localizedDescription ?? "unknown")")
        }
    }
}

关键点:

  • applicationDidFinishLaunchingWithOptions 中调用
  • authenticateHandler 是全局的,只设置一次
  • 认证是异步的,可能弹出系统登录界面

提交分数到循环排行榜

11:16

有两种提交方式:类方法和实例方法。

类方法(推荐用于简单场景):

func submitScore(_ score: Int) {
    GKLeaderboard.submitScore(score,
                              leaderboard: "DailyHighScore",
                              playerScope: .global,
                              timeScope: .allTime)
}

关键点:

  • 分数会提交到当前 active 的 occurrence
  • 如果没有 active occurrence,提交会失败
  • timeScope 对于循环排行榜必须是 .allTime

实例方法(确保不跨 occurrence):

func submitScoreSafely(_ score: Int) {
    // 先加载当前 occurrence
    GKLeaderboard.loadLeaderboards(IDs: ["DailyHighScore"]) { leaderboards, error in
        guard let leaderboard = leaderboards?.first else { return }

        // 在 completion handler 中提交
        leaderboard.submitScore(score,
                                playerScope: .global,
                                timeScope: .allTime)
    }
}

关键点:

  • 先加载 GKLeaderboard 实例,再提交
  • 确保分数提交到同一个 occurrence
  • 适用于对时效性要求极高的场景(如每小时挑战)

显示 Game Center 系统排行榜

12:57

@IBAction func showLeaderboardVC(_ sender: UIButton) {
    let vc = GKGameCenterViewController(leaderboardID: "DailyHighScore",
                                       playerScope: .global,
                                       timeScope: .allTime)
    vc.gameCenterDelegate = self
    present(vc, animated: true)
}

extension TitleScreenViewController: GKGameCenterViewControllerDelegate {
    func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) {
        gameCenterViewController.dismiss(animated: true)
    }
}

关键点:

  • playerScope.global(全球)和 .friendsOnly(仅好友)两种
  • 循环排行榜的 timeScope 固定为 .allTime
  • 实现 gameCenterViewControllerDidFinish 处理关闭

显示实时排行榜

16:19

在游戏过程中显示当前前 5 名的实时排名:

func updateLeaderboardNode() {
    // 加载排行榜
    GKLeaderboard.loadLeaderboards(IDs: ["DailyHighScore"]) { leaderboards, error in
        guard let leaderboard = leaderboards?.first else { return }

        // 加载前 5 名
        leaderboard.loadEntries(for: .global,
                              timeScope: .allTime,
                              range: NSRange(location: 1, length: 5)) { entries, error in
            guard let entries = entries else { return }

            // 转换为自定义 Entry 结构
            let leaderboardEntries = entries.1.map { entry in
                LeaderboardEntry(name: entry.player.displayName,
                               score: entry.score)
            }

            // 更新 UI
            self.leaderboardNode?.updateEntries(leaderboardEntries)
        }
    }
}

// 在每次得分更新时调用
func submitScore(_ score: Int) {
    GKLeaderboard.submitScore(score,
                              leaderboard: "DailyHighScore",
                              playerScope: .global,
                              timeScope: .allTime)

    // 更新实时排行榜
    updateLeaderboardNode()
}

关键点:

  • loadEntries(for:timeScope:range:)range 参数控制获取的排名范围
  • entries 是一个元组 (totalPlayerCount, entryArray)
  • submitScore 的 completion 中调用,保持排行榜实时更新

对比当前和历史排名

21:01

用 Swift async/await 简化跨周期排名对比:

func addRankToGameMenu() async {
    do {
        // 加载当前 occurrence
        let leaderboard = try await GKLeaderboard.loadLeaderboards(IDs: ["DailyHighScore"]).first

        // 并发加载当前排名和上期排名
        async let currentEntries = leaderboard?.loadEntries(for: .global,
                                                           timeScope: .allTime)
        async let previousLeaderboard = leaderboard?.loadPreviousOccurrence()
        async let previousEntries = previousLeaderboard?.loadEntries(for: .global,
                                                                     timeScope: .allTime)

        // 等待结果
        let (_, currentPlayerEntries) = try await currentEntries ?? ([], [])
        let (_, previousPlayerEntries) = try await previousEntries ?? ([], [])

        // 提取本地玩家排名
        let currentRank = currentPlayerEntries.first?.rank
        let previousRank = previousPlayerEntries.first?.rank

        // 显示排名变化
        gameMenuNode.addRankNode(currentRank: currentRank,
                                 previousRank: previousRank)

    } catch {
        print("Error loading ranks: \(error.localizedDescription)")
    }
}

// 在游戏结束时调用
func timeIsUp() {
    // ...
    Task {
        await addRankToGameMenu()
    }
}

关键点:

  • loadPreviousOccurrence() 加载最近结束的 occurrence
  • async let 实现并发加载,提升性能
  • 玩家可以在 30 天内查看历史 occurrence 的数据

详细内容

循环排行榜的工作原理

系统按配置的周期自动创建 occurrence:

Start Date: June 11, 9:00 AM
Duration: 1 day
Restarts Every: 1 day

Occurrence 1: June 11, 9:00 AM → June 12, 9:00 AM
Occurrence 2: June 12, 9:00 AM → June 13, 9:00 AM
Occurrence 3: June 13, 9:00 AM → June 14, 9:00 AM
...

关键点:

  • 每个 occurrence 是独立的排行榜实例
  • occurrence 过期后不能再提交分数
  • 过期的 occurrence 数据保留 30 天

Context 参数的使用

submitScore 方法支持 context 参数,用于存储游戏特定信息:

let context = 0  // 可以是任意整数
GKLeaderboard.submitScore(score,
                          leaderboard: "DailyHighScore",
                          playerScope: .global,
                          timeScope: .allTime,
                          context: context)

context 的常见用途:

  • 记录提交分数时使用的角色/难度
  • 标记特殊事件(如双倍得分期间)
  • 验证分数合法性

错误处理

常见错误及其原因:

  • GKErrorInvalidPlayer:玩家未认证
  • GKErrorInvalidScopetimeScope 设置错误
  • GKErrorInvalidArgument:分数超出配置的有效范围
  • GKErrorAuthenticationFailed:网络问题或认证失败

核心启发

  1. 做每日挑战活动

    • 做什么:每天零点开启一个新排行榜,持续 24 小时,主题可以是「最高连击」、「最快通关」等
    • 为什么值得做:固定周期的活动能培养玩家习惯,日活会形成自然波峰。循环排行榜零服务端成本
    • 怎么开始:App Store Connect 配置 24 小时循环排行榜,游戏内展示倒计时和当前排名
  2. 做赛季制竞技场

    • 做什么:每月开启一个新赛季排行榜,赛季结束时根据排名发放奖励
    • 为什么值得做:赛季制让玩家有明确的参与目标和时间压力,比永久排行榜更能激发竞争
    • 怎么开始Duration = 30 days,赛季结束时用 loadPreviousOccurrence 读取冠军数据并分发奖励
  3. 做跨周期进度可视化

    • 做什么:在游戏结束时显示玩家与上期排名的对比:「上升 15 名」或「下降 3 名」
    • 为什么值得做:让玩家看到自己的成长轨迹,比单纯显示当前排名更有成就感
    • 怎么开始:对比 loadEntries 的当前 occurrence 和 loadPreviousOccurrence 的排名
  4. 做好友接力排行榜

    • 做什么:每周排行榜的主题是「和好友一起刷分」,计算好友总分或最高分
    • 为什么值得做:社交激励比单纯竞争更有效,玩家会主动拉好友参与
    • 怎么开始:用 Friends API 获取好友列表,然后在 playerScope: .friendsOnly 的排行榜中显示
  5. 做限时挑战模式

    • 做什么:每小时开启一次 15 分钟的极速挑战,看谁在限定时间内得分最高
    • 为什么值得做:高频短周期的活动能显著提升用户粘性,玩家会准时回来参加
    • 怎么开始Duration = 15 minutesRestarts Every = 1 hour,游戏内显示下次挑战倒计时

关联 Session

评论

GitHub Issues · utterances