# IGP.UnitySDK.GameKit 开发者接入指南

这份文档面向 Unity 游戏开发者，说明如何在 Unity 工程中接入和调用 `IGP.UnitySDK.GameKit`。

当前 GameKit 包包含 Profile 当前用户资料、Leaderboard 排行榜和 Share 分享能力。它们走主包 `IGP.UnitySDK` 的 desktop session 路径；游戏侧不持有用户 JWT，用户上下文、API 转发或桌面自主流程由 desktop 处理。SDK 会从 `IGPConfig.appId` 读取当前游戏 AppID，并按 Runtime API 契约写入需要的 `appId/gameId`。

## 安装

先安装主包：

```text
adapters/unity/Runtime/IGP.UnitySDK/package.json
```

再安装 GameKit 可选包：

```text
adapters/unity/Runtime/IGP.UnitySDK.GameKit/package.json
```

正式发布包对应：

```text
cn.indiegp.sdk.unity.game-kit-<version>.unitypackage
```

代码命名空间：

```csharp
using IGP.UnitySDK.GameKit;
```

## 调用路径

Leaderboard 调用通过下面路径完成：

```text
Unity game
 -> IGPLeaderboard
 -> IGPRuntimeManager.LeaderboardForwardAsync
 -> desktop session command LeaderboardForward
 -> Curio desktop
 -> Runtime Leaderboard API
```

游戏不需要直接访问后端 API，不需要持有用户 JWT，也不需要自己拼认证 headers。

## 前置条件

1. `IGPConfig.appId` 已配置。
2. 场景里有且只有一个长期有效的 `IGPRuntimeManager`。
3. 游戏启动流程已调用 `IGPRuntimeManager.InitializeAsync()`。
4. desktop session attach 成功。
5. 读取 Profile 时，desktop 当前用户已登录且返回的 `capabilities.userContext` 为 `true`。
6. 查询 Players 时，desktop 当前用户已登录且返回的 `capabilities.userContext` 为 `true`。
7. 使用排行榜时，desktop 返回的 `capabilities.leaderboard` 为 `true`。
8. 使用分享时，desktop 返回的 `capabilities.share` 为 `true`。

如果 capability 不可用，调用会失败，错误码为 `DESKTOP_SESSION_CAPABILITY_MISSING`。

## Profile 当前用户资料

`IGPProfile.GetProfileAsync(...)` 通过 desktop session command `GetDesktopUserProfile` 主动获取当前登录用户最新的完整 `SessionResponse.profile`。desktop 负责认证和 session 刷新，游戏不持有平台 JWT。

```csharp
using IGP.UnitySDK;
using IGP.UnitySDK.GameKit;

public sealed class ProfileDriver
{
    private IGPRuntimeManager runtimeManager;

    public async System.Threading.Tasks.Task LoadProfileAsync()
    {
        IGPUserProfile profile =
            await IGPProfile.GetProfileAsync(runtimeManager);

        UnityEngine.Debug.Log($"Avatar: {profile.avatarUrl}");
    }
}
```

### 返回字段

类型：`IGPUserProfile`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `avatar` | `IGPProfileAvatarSelection?` | 当前头像选择，包含 `assetKey`、`displayName`、`imageUrl`。 |
| `avatarUrl` | `string?` | 当前头像 URL。 |
| `avatarFrame` | `IGPProfileAvatarFrameSelection?` | 当前头像框及 `renderConfig`。 |
| `background` | `IGPProfileBackgroundSelection?` | 当前主页背景选择。 |
| `backgroundUrl` | `string?` | 当前主页背景 URL。 |
| `bio` | `string?` | 用户简介。 |
| `nameChanges` | `int?` | 已使用的改名次数。 |
| `lastChangedAt` | `string?` | 上次改名时间，ISO 时间字符串。 |
| `lastActiveAt` | `string?` | 最近活跃时间，ISO 时间字符串。 |
| `pendingNicknameReview` | `IGPPendingNicknameReview?` | 当前待处理昵称审核。 |

`avatarFrame.renderConfig` 包含画布、头像区域、遮罩和前后景/动态效果图层。profile 中的头像是选择信息和 URL；需要统一的 `256x256 PNG` 字节时，调用主包已有的 `runtimeManager.GetDesktopUserAvatarAsync()`。

### 错误处理

失败统一抛出 `IGPProfileException`：

| Code | 说明 |
| --- | --- |
| `DESKTOP_SESSION_REQUIRED` | desktop session 尚未 attach。 |
| `DESKTOP_USER_CONTEXT_REQUIRED` | desktop 未登录或没有可用用户上下文。 |
| `PROFILE_UNAVAILABLE` | 当前认证 session 未返回 profile。 |
| `PROFILE_INVALID_RESPONSE` | desktop 返回的 profile JSON 为空或无法解析。 |

## Players 玩家简单资料

`IGPPlayers.GetProfilesAsync(...)` 是唯一的玩家资料查询入口，一次接受 1 到 50 个用户 ID。查询单个玩家时传入单元素数组。

```csharp
IGPPlayerProfile[] profiles = await IGPPlayers.GetProfilesAsync(
    runtimeManager,
    new[] { "user-2", "user-3" });
```

重复 ID 按第一次出现的位置去重，不存在的用户从结果中省略。每个 `IGPPlayerProfile` 严格只包含 `id`、`nickname`、`avatarUrl` 和静态 `avatarFrameUrl`。

## Leaderboard 最小接入

```csharp
using IGP.UnitySDK;
using IGP.UnitySDK.GameKit;

public sealed class LeaderboardDriver
{
    private IGPRuntimeManager runtimeManager;

    public async void ListLeaderboards()
    {
        IGPListLeaderboardsByGameResult result =
            await IGPLeaderboard.ListLeaderboardsByGameAsync(
                runtimeManager,
                new IGPListLeaderboardsByGameOptions
                {
                    leaderboardId = "global-score",
                });

        foreach (IGPLeaderboardMetadataItem item in result.leaderboards)
        {
            // item.leaderboardId
            // item.status
            // item.displayName
        }
    }

    public async void ShowLeaderboard(string leaderboardId)
    {
        IGPGetLeaderboardResult result =
            await IGPLeaderboard.GetLeaderboardAsync(
                runtimeManager,
                leaderboardId,
                new IGPGetLeaderboardQuery
                {
                    start = 0,
                    count = 20,
                    includeSelf = true,
                });

        foreach (IGPLeaderboardEntry entry in result.entries)
        {
            // entry.rank
            // entry.user.id
            // entry.user.nickname
            // entry.value
        }
    }

    public async void SubmitScore(string leaderboardId, long score)
    {
        IGPSubmitLeaderboardScoreResult result =
            await IGPLeaderboard.SubmitLeaderboardScoreAsync(
                runtimeManager,
                leaderboardId,
                score,
                new IGPSubmitLeaderboardScoreOptions
                {
                    eventId = "match-20260626-0001",
                    sessionId = "session-abc",
                });

        // result.success
        // result.duplicated
        // result.value
    }

    public async void QueryFriends(string leaderboardId, string[] userIds)
    {
        IGPQueryLeaderboardScoresResult result =
            await IGPLeaderboard.QueryLeaderboardScoresAsync(
                runtimeManager,
                leaderboardId,
                userIds);

        foreach (IGPLeaderboardScoreItem score in result.scores)
        {
            // score.userId
            // score.value: null means the user has never submitted a score
        }
    }
}
```

## API 与路由

| 方法 | HTTP | Route | 请求 | 响应 |
| --- | --- | --- | --- | --- |
| `ListLeaderboardsByGameAsync` | GET | `games/:gameId` | `IGPListLeaderboardsByGameOptions` | `IGPListLeaderboardsByGameResult` |
| `GetLeaderboardAsync` | GET | `:leaderboardId` | `IGPGetLeaderboardQuery` | `IGPGetLeaderboardResult` |
| `SubmitLeaderboardScoreAsync` | POST | `:leaderboardId/score` | `IGPSubmitLeaderboardScoreRequest` | `IGPSubmitLeaderboardScoreResult` |
| `QueryLeaderboardScoresAsync` | POST | `:leaderboardId/scores/query` | `IGPQueryLeaderboardScoresRequest` | `IGPQueryLeaderboardScoresResult` |

Leaderboard 转发 payload 由 SDK 生成，游戏侧只传排行榜业务参数。下面的 `appId/gameId` 由 SDK 从 `IGPConfig.appId` 写入，不需要游戏业务层传入：

```json
{
  "method": "POST",
  "appId": 10010,
  "route": ":leaderboardId/score",
  "pathParams": {
    "leaderboardId": "global-score"
  },
  "body": {
    "gameId": 10010,
    "value": 100,
    "eventId": "match-20260626-0001"
  }
}
```

## 请求字段

### 列出游戏排行榜请求选项

类型：`IGPListLeaderboardsByGameOptions`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `leaderboardId` | `string?` | 可选过滤条件；不传时返回该游戏下全部排行榜元数据。 |
| `period` | `string?` | 当前只支持 `PERMANENT`。为空时 SDK 默认填 `PERMANENT`。 |

### 获取排行榜查询参数

类型：`IGPGetLeaderboardQuery`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `period` | `string?` | 当前只支持 `PERMANENT`。为空时 SDK 默认填 `PERMANENT`。 |
| `start` | `int?` | 查询起始位置，`0` 表示第一名。为空时 SDK 默认填 `0`。 |
| `count` | `int?` | 返回条目数量，范围 1 到 100。为空时 SDK 默认填 `20`。 |
| `includeSelf` | `bool?` | 为 `true` 时请求 API 返回当前用户自己的排名；无成绩时 `selfEntry` 为 `null`。 |

### 提交排行榜分数请求

类型：`IGPSubmitLeaderboardScoreRequest`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 AppID，SDK 会从 `IGPConfig.appId` 写入请求 body。 |
| `value` | `long` | 当前玩家提交的整数分数，分数越高排名越靠前。 |
| `eventId` | `string?` | 可选幂等键。同一次结算重试时应复用同一个值。 |
| `sessionId` | `string?` | 可选游戏会话 id，透传给后端。 |

### 查询用户排行榜分数请求

类型：`IGPQueryLeaderboardScoresRequest`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 AppID，SDK 会从 `IGPConfig.appId` 写入请求 body。 |
| `userIds` | `string[]` | 要查询的用户 id，长度 1 到 50。 |
| `period` | `string?` | 当前只支持 `PERMANENT`。为空时 SDK 默认填 `PERMANENT`。 |

## 响应字段

### 列出游戏排行榜响应

类型：`IGPListLeaderboardsByGameResult`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 id。 |
| `leaderboards` | `IGPLeaderboardMetadataItem[]` | 排行榜元数据列表。 |

### 排行榜元数据

类型：`IGPLeaderboardMetadataItem`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 id。 |
| `leaderboardId` | `string` | 排行榜 id。 |
| `period` | `string` | 当前为 `PERMANENT`。 |
| `status` | `string` | 排行榜状态，例如 `ACTIVE`。 |
| `displayName` | `string?` | 展示名。 |
| `description` | `string?` | 描述。 |
| `config` | `object?` | 服务端配置摘要。 |
| `createdAt` | `string` | ISO 时间字符串。 |
| `updatedAt` | `string` | ISO 时间字符串。 |

### 获取排行榜响应

类型：`IGPGetLeaderboardResult`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 id。 |
| `leaderboardId` | `string` | 排行榜 id。 |
| `period` | `string` | 当前为 `PERMANENT`。 |
| `entries` | `IGPLeaderboardEntry[]` | 榜单条目。返回条目数量不保证一定等于请求的 `count`。 |
| `selfEntry` | `IGPLeaderboardEntry?` | 请求 `includeSelf=true` 时返回当前用户排名；未上榜时为 `null`。 |
| `generatedAt` | `string` | ISO 时间字符串。 |

### 排行榜条目

类型：`IGPLeaderboardEntry`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `rank` | `int` | 排名。 |
| `user` | `IGPLeaderboardUser` | 用户摘要。 |
| `value` | `long` | 分数。 |

### 提交排行榜分数响应

类型：`IGPSubmitLeaderboardScoreResult`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | `bool` | 是否成功。 |
| `duplicated` | `bool` | `eventId` 命中已有提交时为 `true`。 |
| `value` | `long` | 本次提交分数。 |

### 查询用户排行榜分数响应

类型：`IGPQueryLeaderboardScoresResult`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gameId` | `int` | 游戏 id。 |
| `leaderboardId` | `string` | 排行榜 id。 |
| `period` | `string` | 当前为 `PERMANENT`。 |
| `scores` | `IGPLeaderboardScoreItem[]` | 查询结果。未提交过成绩的用户 `value` 为 `null`。 |
| `generatedAt` | `string` | ISO 时间字符串。 |

## 本地校验

调用前 SDK 会做基础校验，失败时不会发送 desktop command：

- `leaderboardId`：非空，trim 后最大 96 字符。
- `IGPConfig.appId`：必填，SDK 会用它填充排行榜转发 payload 的 `appId/gameId`。
- `start`：大于等于 0 的整数。
- `count`：1 到 100 的整数。
- `eventId` / `sessionId`：如果传入，trim 后非空。
- `userIds`：长度 1 到 50，每项非空。
- `period`：只能是 `PERMANENT`。

后端仍然是最终规则来源；SDK 本地校验只负责提前拦截明显错误。

## 错误处理

失败统一抛出 `IGPLeaderboardException`：

```csharp
try
{
    await IGPLeaderboard.GetLeaderboardAsync(runtimeManager, "global-score");
}
catch (IGPLeaderboardException ex)
{
    UnityEngine.Debug.LogError(
        $"Leaderboard failed: code={ex.Code}, status={ex.HttpStatus()}, upstream={ex.UpstreamJson}");
}
```

常见错误码：

| 错误码 | 说明 |
| --- | --- |
| `DESKTOP_SESSION_CAPABILITY_MISSING` | desktop session 没有启用 leaderboard capability。 |
| `LEADERBOARD_HTTP_<status>` | Runtime Leaderboard API 返回非 2xx。 |
| `APP_ID_MISMATCH` | 当前 appId 与 desktop/session 上下文不一致。 |

`UpstreamJson` 会保留 API 返回的原始错误 body，便于游戏侧日志和排查。

## 当前限制

- 只支持四个 Runtime Leaderboard 接口：查询游戏排行榜元数据、读取排行榜区间、批量查询用户分数、提交当前玩家成绩。
- 不支持创建、审核、启用排行榜。
- 不支持 cursor、includeMe。
- `period` 当前只支持 `PERMANENT`。
- 不保证同分顺序。
- 不保证读取榜单时返回条目数量一定等于请求的 `count`。

---

## Share 分享

Share 调用通过下面路径完成：

```text
Unity game
 -> IGPShare
 -> IGPRuntimeManager.RequestDesktopShareAsync
 -> desktop session command RequestDesktopShare
 -> Curio desktop
 -> desktop share handler / platform share API
```

SDK 不直接发布内容，只提交版本化 payload。文本和扩展数据放在 `contentJson`；图片是二进制字节，放在同一个 desktop command 的 `contentBytes`。登录、确认面板、发布、取消和失败流程由 desktop 自主控制，并通过 `IGPShareResult` 返回。

### 最小接入

```csharp
using IGP.UnitySDK;
using IGP.UnitySDK.GameKit;

public sealed class ShareDriver
{
    private IGPRuntimeManager runtimeManager;

    public async void ShareScore(byte[] posterPngBytes)
    {
        IGPShareResult result = await IGPShare.ShareAsync(
            runtimeManager,
            new IGPShareRequest
            {
                contentType = "score-card",
                title = "New record",
                text = "I scored 12345",
                templateId = "score",
                images = new[]
                {
                    new IGPShareImage
                    {
                        mimeType = "image/png",
                        bytes = posterPngBytes,
                        width = 1280,
                        height = 720,
                        fileName = "score.png",
                    },
                },
                data = new
                {
                    score = 12345,
                },
                metadata = new
                {
                    matchId = "match-20260709-0001",
                },
            });

        UnityEngine.Debug.Log($"Share status: {result.status}, id={result.shareId}");
    }
}
```

### 请求字段

类型：`IGPShareRequest`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `schemaVersion` | `int` | 固定为 `1`。 |
| `contentType` | `string?` | 游戏自定义内容类型，例如 `score-card`、`level-share`。 |
| `title` | `string?` | 分享标题。 |
| `text` | `string?` | 分享文本。 |
| `images` | `IGPShareImage[]` | 多张图片，图片字节不进入 JSON。 |
| `templateId` | `string?` | 游戏和 desktop 约定的模板 id。 |
| `data` | `object?` | 游戏自定义结构化数据。 |
| `metadata` | `object?` | 游戏自定义元数据，例如 matchId、levelId、traceId。 |

类型：`IGPShareImage`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `mimeType` | `string` | `image/png` 或 `image/jpeg`。 |
| `bytes` | `byte[]` | 图片原始字节；SDK 会写入 `contentBytes`。 |
| `width` | `int?` | 图片宽度。 |
| `height` | `int?` | 图片高度。 |
| `fileName` | `string?` | 可选文件名。 |
| `altText` | `string?` | 可选替代文本。 |

`IGPShareImage` 只定义数据字段；调用侧按图片实际格式填入 `mimeType` 和 `bytes`。

### 转发形状

SDK 发送给 desktop 的 `contentJson` 中，图片只保留描述符：

```json
{
  "schemaVersion": 1,
  "appId": 10010,
  "contentType": "score-card",
  "text": "I scored 12345",
  "images": [
    {
      "mimeType": "image/png",
      "byteOffset": 0,
      "byteLength": 1024,
      "width": 1280,
      "height": 720,
      "fileName": "score.png"
    }
  ],
  "data": {
    "score": 12345
  }
}
```

图片原始字节拼接在 `contentBytes` 中。desktop 用 `byteOffset` 和 `byteLength` 读取每张图片。

### 返回结果

类型：`IGPShareResult`

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `success` | `bool` | 分享流程结果。 |
| `code` | `string` | desktop 分享结果码，例如 `SHARE_COMPLETED`、`SHARE_CANCELLED`、`SHARE_LOGIN_REQUIRED`。 |
| `shareId` | `string` | desktop 或平台返回的分享 id。 |
| `status` | `string` | 分享状态，例如 `completed`、`cancelled`、`loginRequired`。 |
| `message` | `string` | 可展示或记录的结果说明。 |
| `data` | `object?` | desktop 返回的扩展结果。 |
| `contentJson` | `string` | desktop 原始结果 JSON，便于排查问题。 |

用户取消是正常结果，例如 `success=false`、`status=cancelled`、`code=SHARE_CANCELLED`，不会抛异常。

### 本地校验

- `IGPConfig.appId` 必填。
- `schemaVersion` 必须是 `1`。
- 请求至少包含文本、图片、模板 id、`data` 或 `metadata` 之一。
- 图片 MIME 只支持 `image/png` 和 `image/jpeg`。
- 所有图片原始字节合计最大 `10 MiB`。
- 超过 10 MiB 时抛出 `IGPShareException`，`Code = SHARE_IMAGES_TOO_LARGE`，不会发送 desktop command。

### 错误处理

desktop 分享流程的业务结果通过 `IGPShareResult` 返回。桥协议或 desktop 命令失败才会抛出 `IGPShareException`：

- `Code`：desktop 返回的稳定错误码。
- `UpstreamJson`：desktop 原始错误 body（如果有）。
