cn.deeprespond.im.game 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/CHANGELOG.md.meta +7 -0
- package/LICENSE.md +21 -0
- package/LICENSE.md.meta +7 -0
- package/README.md +83 -2
- package/README.md.meta +7 -0
- package/Runtime/Api.cs +368 -0
- package/Runtime/Api.cs.meta +11 -0
- package/Runtime/AssemblyInfo.cs +3 -0
- package/Runtime/AssemblyInfo.cs.meta +11 -0
- package/Runtime/DeepRespond.Im.Game.asmdef +14 -0
- package/Runtime/DeepRespond.Im.Game.asmdef.meta +7 -0
- package/Runtime/Errors.cs +199 -0
- package/Runtime/Errors.cs.meta +11 -0
- package/Runtime/GameRoom.cs +786 -0
- package/Runtime/GameRoom.cs.meta +11 -0
- package/Runtime/ImGameClient.cs +212 -0
- package/Runtime/ImGameClient.cs.meta +11 -0
- package/Runtime/Internal/FrameAssembler.cs +67 -0
- package/Runtime/Internal/FrameAssembler.cs.meta +11 -0
- package/Runtime/Internal/Json.cs +287 -0
- package/Runtime/Internal/Json.cs.meta +11 -0
- package/Runtime/Internal/Util.cs +119 -0
- package/Runtime/Internal/Util.cs.meta +11 -0
- package/Runtime/Internal.meta +8 -0
- package/Runtime/Protocol/Gcm.cs +173 -0
- package/Runtime/Protocol/Gcm.cs.meta +11 -0
- package/Runtime/Protocol/Kcp.cs +541 -0
- package/Runtime/Protocol/Kcp.cs.meta +11 -0
- package/Runtime/Protocol/Messages.cs +638 -0
- package/Runtime/Protocol/Messages.cs.meta +11 -0
- package/Runtime/Protocol/Packet.cs +158 -0
- package/Runtime/Protocol/Packet.cs.meta +11 -0
- package/Runtime/Protocol.meta +8 -0
- package/Runtime/SdkInfo.cs +9 -0
- package/Runtime/SdkInfo.cs.meta +11 -0
- package/Runtime/Transport/GameConnection.cs +105 -0
- package/Runtime/Transport/GameConnection.cs.meta +11 -0
- package/Runtime/Transport/UdpConnection.cs +292 -0
- package/Runtime/Transport/UdpConnection.cs.meta +11 -0
- package/Runtime/Transport/WsConnection.cs +217 -0
- package/Runtime/Transport/WsConnection.cs.meta +11 -0
- package/Runtime/Transport.meta +8 -0
- package/Runtime/Types.cs +242 -0
- package/Runtime/Types.cs.meta +11 -0
- package/Runtime.meta +8 -0
- package/Samples~/QuickStart/GameRoomQuickStart.cs +119 -0
- package/package.json +28 -4
- package/package.json.meta +7 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2026-11-10
|
|
4
|
+
|
|
5
|
+
首个版本。
|
|
6
|
+
|
|
7
|
+
- 登录:业务服务端签发的登录凭证、用户名密码;access token 过期时自动续期
|
|
8
|
+
- 游戏房间:加入(UDP 优先,连不上自动改用 WebSocket)、找回当前房间、离开
|
|
9
|
+
- 帧同步:按帧号顺序交付、去重、自动补帧;`SendInput` 按房间帧率节流
|
|
10
|
+
- 自定义消息(可靠 / 不可靠)、准备、开始、快照
|
|
11
|
+
- 断线自动恢复,超过重连窗口后结束;结束原因和结算结果
|
|
12
|
+
- 回调默认在 Unity 主线程执行,也可以在 `DispatchEvents()` 里手动执行
|
|
13
|
+
- 示例:Quick Start
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 深应智能(DeepRespond)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/LICENSE.md.meta
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# DeepRespond IM Game(Unity)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
游戏房间客户端 SDK:加入 IM 的游戏房间,经 UDP(KCP + AES-256-GCM)连接游戏节点,UDP 不通时自动改走 WSS;帧同步收发、自定义消息、断线恢复、离开。
|
|
4
|
+
|
|
5
|
+
- 包名 `cn.deeprespond.im.game`,版本 `1.0.0`,命名空间 `DeepRespond.Im.Game`
|
|
6
|
+
- Unity 2021.3 LTS 起(API Compatibility Level 用 .NET Standard 2.1),Mono 和 IL2CPP
|
|
7
|
+
- iOS、Android、Windows、macOS、Linux;**不支持 WebGL**(浏览器里没有 UDP),WebGL 游戏用 Web SDK 的 `im.game`
|
|
8
|
+
- 没有第三方依赖
|
|
9
|
+
- 接入文档:<https://www.syznlink.com/docs/game/unity>
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
**方式一:npm 仓库(可以在 Package Manager 里升级)**。在工程的 `Packages/manifest.json` 里加上:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"scopedRegistries": [
|
|
18
|
+
{ "name": "DeepRespond", "url": "https://registry.npmjs.org", "scopes": ["cn.deeprespond"] }
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"cn.deeprespond.im.game": "1.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**方式二:下载安装包**。从接入文档下载 `cn.deeprespond.im.game-1.0.0.tgz`,放进工程目录(如 `Packages/`),Package Manager →「+」→ Add package from tarball 选择它。
|
|
27
|
+
|
|
28
|
+
## 许可证
|
|
29
|
+
|
|
30
|
+
MIT,见 `LICENSE.md`。
|
|
31
|
+
|
|
32
|
+
## 接入
|
|
33
|
+
|
|
34
|
+
整个流程分两边:
|
|
35
|
+
|
|
36
|
+
| 谁 | 做什么 |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| 业务服务端(服务端 SDK) | 签发 IM 登录凭证(`login-tickets`);建房(`PUT /game/rooms/{room_id}`);给玩家签加入票据(`POST /game/rooms/{room_id}/tickets`);接收结束回调 |
|
|
39
|
+
| 游戏客户端(本 SDK) | 用登录凭证登录;用加入票据加入房间;收发操作和帧 |
|
|
40
|
+
|
|
41
|
+
```csharp
|
|
42
|
+
using DeepRespond.Im.Game;
|
|
43
|
+
using UnityEngine;
|
|
44
|
+
|
|
45
|
+
var im = new ImGameClient(new ImGameClientOptions {
|
|
46
|
+
ApiUrl = "https://api.example.com",
|
|
47
|
+
AppKey = "<App Key>",
|
|
48
|
+
DeviceId = SystemInfo.deviceUniqueIdentifier,
|
|
49
|
+
Platform = Application.platform == RuntimePlatform.Android ? "android"
|
|
50
|
+
: Application.platform == RuntimePlatform.IPhonePlayer ? "ios" : null, // null 时按运行系统判断
|
|
51
|
+
Logger = (level, msg) => { if (level >= GameLogLevel.Info) Debug.Log(msg); },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await im.Auth.LoginWithTicketAsync(username, loginTicket); // 登录凭证由业务服务端签发
|
|
55
|
+
|
|
56
|
+
var room = await im.Game.JoinAsync(roomId, new JoinOptions { Ticket = joinTicket });
|
|
57
|
+
room.OnRoomState += e => { if (e.State == GameRoomState.Started) game.Begin(e.StartFrame); };
|
|
58
|
+
room.OnFrame += f => game.Step(f.Frame, f.Inputs); // 严格按帧号顺序,不重复
|
|
59
|
+
room.OnMember += e => ui.UpdateSeat(e.Seat, e.Type);
|
|
60
|
+
room.OnClose += e => ui.ShowResult(e.Reason, e.ResultJson);
|
|
61
|
+
room.Ready();
|
|
62
|
+
|
|
63
|
+
// Update 里每帧调用即可:SDK 按房间帧率节流,只发最新的一份
|
|
64
|
+
room.SendInput(EncodeInput());
|
|
65
|
+
|
|
66
|
+
await room.LeaveAsync();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
完整示例:Package Manager 里本包的 Samples →「Quick Start」导入 `GameRoomQuickStart.cs`,挂到场景物体上,填好地址和票据运行。
|
|
70
|
+
|
|
71
|
+
## 要点
|
|
72
|
+
|
|
73
|
+
| 项 | 说明 |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| 回调线程 | 默认在调用 `JoinAsync` 的线程(Unity 主线程)执行;`EventMode = GameEventMode.Manual` 时在 `room.DispatchEvents()` 里执行 |
|
|
76
|
+
| 连接 | 先 UDP,1.5 秒 × 2 次没回复改 WSS;`JoinOptions.Transport` 可以固定为 `Udp` 或 `Ws` |
|
|
77
|
+
| 断线 | 5 秒没收到包判定断线,立即恢复,之后 1、2、4、4… 秒重试,超过房间的重连窗口结束(`OnClose.Reason = "timeout"`);状态经过 `Reconnecting`(/ `Rejoining`)回到 `Connected`,缺的帧自动补齐 |
|
|
78
|
+
| 发操作 | `SendInput` ≤ 128 字节;每 `1000 / TickRate` 毫秒最多发一次(节点对超过帧率 2 倍的操作会判为滥用并移出房间),没连上返回 false |
|
|
79
|
+
| 自定义消息 | `SendCustom(data, to, reliable)` ≤ 1KB;可靠的保证顺序和送达 |
|
|
80
|
+
| 应用到后台 | 系统冻结网络后回到前台按断线处理,自动恢复 |
|
|
81
|
+
| App 重启 | `await im.Game.GetCurrentAsync()` 找回房间,再 `JoinAsync`(不带票据也行,同设备会拿回原座位) |
|
|
82
|
+
| 登录会话 | 只在内存;要持久化就监听 `im.Auth.SessionChanged` 自己加密保存,下次 `im.Auth.UseSession(saved)`。access token 过期时 SDK 自动用 refresh token 续期 |
|
|
83
|
+
| 错误提示 | `GameErrors.KindOf(ex)`:`TicketInvalid`、`GameDisabled`、`RoomClosed`、`RoomFull`、`JoinedOnOtherDevice`、`NodeUnavailable`、`UpgradeRequired`、`Network` |
|
|
84
|
+
| iOS | 节点地址要用域名(App Store 要求支持纯 IPv6 网络,IPv4 字面地址在 NAT64 下连不上) |
|
package/README.md.meta
ADDED
package/Runtime/Api.cs
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.Net.Http;
|
|
4
|
+
using System.Net.Http.Headers;
|
|
5
|
+
using System.Text;
|
|
6
|
+
using System.Threading;
|
|
7
|
+
using System.Threading.Tasks;
|
|
8
|
+
using DeepRespond.Im.Game.Internal;
|
|
9
|
+
|
|
10
|
+
namespace DeepRespond.Im.Game
|
|
11
|
+
{
|
|
12
|
+
/// <summary>调 IM 的 HTTP 接口:JSON 请求、错误解析、401 自动续期一次。</summary>
|
|
13
|
+
internal sealed class ImHttp : IDisposable
|
|
14
|
+
{
|
|
15
|
+
private readonly HttpClient _http;
|
|
16
|
+
private readonly string _base;
|
|
17
|
+
private readonly Logger _log;
|
|
18
|
+
|
|
19
|
+
/// <summary>当前的 access token;没有登录为 null。</summary>
|
|
20
|
+
public Func<string> Token;
|
|
21
|
+
/// <summary>401 时续期,成功返回 true。</summary>
|
|
22
|
+
public Func<CancellationToken, Task<bool>> Refresh;
|
|
23
|
+
|
|
24
|
+
public ImHttp(string apiUrl, TimeSpan timeout, Logger log, HttpMessageHandler handler = null)
|
|
25
|
+
{
|
|
26
|
+
_base = apiUrl.TrimEnd('/');
|
|
27
|
+
_http = handler != null ? new HttpClient(handler) : new HttpClient();
|
|
28
|
+
_http.Timeout = timeout;
|
|
29
|
+
_log = log;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public void Dispose() => _http.Dispose();
|
|
33
|
+
|
|
34
|
+
public async Task<object> SendAsync(string method, string path, object body, bool auth, CancellationToken ct)
|
|
35
|
+
{
|
|
36
|
+
try
|
|
37
|
+
{
|
|
38
|
+
return await SendOnceAsync(method, path, body, auth, ct).ConfigureAwait(false);
|
|
39
|
+
}
|
|
40
|
+
catch (ImApiException e) when (auth && e.Status == 401 && Refresh != null &&
|
|
41
|
+
(e.Reason == null || e.Reason == "token_expired" || e.Reason == "token_invalid"))
|
|
42
|
+
{
|
|
43
|
+
_log.Info("登录凭证过期,续期后重试");
|
|
44
|
+
if (!await Refresh(ct).ConfigureAwait(false)) throw;
|
|
45
|
+
return await SendOnceAsync(method, path, body, true, ct).ConfigureAwait(false);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async Task<object> SendOnceAsync(string method, string path, object body, bool auth, CancellationToken ct)
|
|
50
|
+
{
|
|
51
|
+
using (var req = new HttpRequestMessage(new HttpMethod(method), _base + path))
|
|
52
|
+
{
|
|
53
|
+
if (body != null) req.Content = new StringContent(Json.Serialize(body), Encoding.UTF8, "application/json");
|
|
54
|
+
if (auth)
|
|
55
|
+
{
|
|
56
|
+
var token = Token?.Invoke();
|
|
57
|
+
if (string.IsNullOrEmpty(token)) throw new InvalidOperationException("还没有登录(先调用 Auth.LoginWithTicketAsync 或 Auth.UseSession)");
|
|
58
|
+
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
59
|
+
}
|
|
60
|
+
HttpResponseMessage resp;
|
|
61
|
+
try
|
|
62
|
+
{
|
|
63
|
+
resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
|
64
|
+
}
|
|
65
|
+
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
66
|
+
{
|
|
67
|
+
throw;
|
|
68
|
+
}
|
|
69
|
+
catch (OperationCanceledException e)
|
|
70
|
+
{
|
|
71
|
+
throw new ImNetworkException($"请求 {path} 超时", e);
|
|
72
|
+
}
|
|
73
|
+
catch (HttpRequestException e)
|
|
74
|
+
{
|
|
75
|
+
throw new ImNetworkException($"请求 {path} 失败:{e.Message}", e);
|
|
76
|
+
}
|
|
77
|
+
using (resp)
|
|
78
|
+
{
|
|
79
|
+
string text = resp.Content != null ? await resp.Content.ReadAsStringAsync().ConfigureAwait(false) : "";
|
|
80
|
+
int status = (int)resp.StatusCode;
|
|
81
|
+
if (status >= 200 && status < 300) return string.IsNullOrWhiteSpace(text) ? null : Json.Parse(text);
|
|
82
|
+
throw ParseError(status, text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
internal static ImApiException ParseError(int status, string text)
|
|
88
|
+
{
|
|
89
|
+
string code = "http_" + status, message = text?.Trim() ?? "", reason = null, requestId = null;
|
|
90
|
+
Dictionary<string, object> details = null;
|
|
91
|
+
try
|
|
92
|
+
{
|
|
93
|
+
var err = Json.Obj(Json.Parse(text), "error");
|
|
94
|
+
if (err != null)
|
|
95
|
+
{
|
|
96
|
+
code = Json.Str(err, "code") ?? code;
|
|
97
|
+
message = Json.Str(err, "message") ?? "";
|
|
98
|
+
requestId = Json.Str(err, "request_id");
|
|
99
|
+
details = Json.Obj(err, "details");
|
|
100
|
+
reason = Json.Str(details, "reason");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (FormatException)
|
|
104
|
+
{
|
|
105
|
+
// 不是 JSON(网关返回的 HTML 等),保留原文
|
|
106
|
+
}
|
|
107
|
+
return new ImApiException(status, code, message, reason, requestId, details);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// <summary>
|
|
112
|
+
/// 最小的 IM 登录:取得 User Token 供游戏接口使用。会话只放在内存,通过 SessionChanged 交给游戏决定是否保存。
|
|
113
|
+
/// </summary>
|
|
114
|
+
public sealed class ImAuth
|
|
115
|
+
{
|
|
116
|
+
private readonly ImHttp _http;
|
|
117
|
+
private readonly ImGameClientOptions _opts;
|
|
118
|
+
private readonly string _platform;
|
|
119
|
+
private readonly SemaphoreSlim _refreshGate = new SemaphoreSlim(1, 1);
|
|
120
|
+
private volatile ImSession _session;
|
|
121
|
+
|
|
122
|
+
internal Action SignedOut;
|
|
123
|
+
|
|
124
|
+
internal ImAuth(ImHttp http, ImGameClientOptions opts, string platform)
|
|
125
|
+
{
|
|
126
|
+
_http = http;
|
|
127
|
+
_opts = opts;
|
|
128
|
+
_platform = platform;
|
|
129
|
+
http.Token = () => _session?.AccessToken;
|
|
130
|
+
http.Refresh = TryRefreshAsync;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// <summary>当前会话,没登录为 null。</summary>
|
|
134
|
+
public ImSession Session => _session;
|
|
135
|
+
|
|
136
|
+
/// <summary>登录、续期、退出后触发(退出或会话失效时参数为 null)。在网络线程触发。</summary>
|
|
137
|
+
public event Action<ImSession> SessionChanged;
|
|
138
|
+
|
|
139
|
+
/// <summary>用业务服务端签发的登录凭证登录(POST /client/v1/sessions,grant_type=ticket)。</summary>
|
|
140
|
+
public Task<ImSession> LoginWithTicketAsync(string username, string ticket, CancellationToken ct = default)
|
|
141
|
+
{
|
|
142
|
+
if (string.IsNullOrEmpty(ticket)) throw new ArgumentException("ticket 不能为空", nameof(ticket));
|
|
143
|
+
return LoginAsync("ticket", username, "ticket", ticket, ct);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// <summary>用户名密码登录(POST /client/v1/sessions,grant_type=password)。</summary>
|
|
147
|
+
public Task<ImSession> LoginWithPasswordAsync(string username, string password, CancellationToken ct = default)
|
|
148
|
+
{
|
|
149
|
+
if (string.IsNullOrEmpty(password)) throw new ArgumentException("password 不能为空", nameof(password));
|
|
150
|
+
return LoginAsync("password", username, "password", password, ct);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async Task<ImSession> LoginAsync(string grant, string username, string field, string secret, CancellationToken ct)
|
|
154
|
+
{
|
|
155
|
+
if (string.IsNullOrEmpty(username)) throw new ArgumentException("username 不能为空", nameof(username));
|
|
156
|
+
if (string.IsNullOrEmpty(_opts.AppKey)) throw new InvalidOperationException("登录需要设置 ImGameClientOptions.AppKey");
|
|
157
|
+
var body = new Dictionary<string, object>
|
|
158
|
+
{
|
|
159
|
+
["app_key"] = _opts.AppKey,
|
|
160
|
+
["grant_type"] = grant,
|
|
161
|
+
["username"] = username,
|
|
162
|
+
[field] = secret,
|
|
163
|
+
["device_id"] = _opts.DeviceId,
|
|
164
|
+
["device_name"] = _opts.DeviceName ?? "",
|
|
165
|
+
["platform"] = _platform,
|
|
166
|
+
["sdk_version"] = SdkInfo.SdkVersion,
|
|
167
|
+
};
|
|
168
|
+
var res = await _http.SendAsync("POST", "/client/v1/sessions", body, false, ct).ConfigureAwait(false);
|
|
169
|
+
return SetSession(res, username);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// <summary>恢复上次保存的会话(不发请求)。</summary>
|
|
173
|
+
public void UseSession(ImSession session)
|
|
174
|
+
{
|
|
175
|
+
if (session == null || string.IsNullOrEmpty(session.AccessToken)) throw new ArgumentException("会话缺少 AccessToken", nameof(session));
|
|
176
|
+
_session = session;
|
|
177
|
+
SessionChanged?.Invoke(session);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// <summary>用 refresh token 续期(POST /client/v1/sessions/refresh)。</summary>
|
|
181
|
+
public async Task RefreshAsync(CancellationToken ct = default)
|
|
182
|
+
{
|
|
183
|
+
var current = _session;
|
|
184
|
+
if (string.IsNullOrEmpty(current?.RefreshToken)) throw new InvalidOperationException("没有 refresh token,不能续期");
|
|
185
|
+
await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
|
|
186
|
+
try
|
|
187
|
+
{
|
|
188
|
+
if (!ReferenceEquals(current, _session)) return; // 别处已经续期
|
|
189
|
+
try
|
|
190
|
+
{
|
|
191
|
+
var res = await _http.SendAsync("POST", "/client/v1/sessions/refresh",
|
|
192
|
+
new Dictionary<string, object> { ["refresh_token"] = current.RefreshToken }, false, ct).ConfigureAwait(false);
|
|
193
|
+
SetSession(res, current.Username);
|
|
194
|
+
}
|
|
195
|
+
catch (ImApiException e) when (e.Status == 401)
|
|
196
|
+
{
|
|
197
|
+
_session = null;
|
|
198
|
+
SessionChanged?.Invoke(null);
|
|
199
|
+
SignedOut?.Invoke();
|
|
200
|
+
throw;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally
|
|
204
|
+
{
|
|
205
|
+
_refreshGate.Release();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async Task<bool> TryRefreshAsync(CancellationToken ct)
|
|
210
|
+
{
|
|
211
|
+
if (string.IsNullOrEmpty(_session?.RefreshToken)) return false;
|
|
212
|
+
try
|
|
213
|
+
{
|
|
214
|
+
await RefreshAsync(ct).ConfigureAwait(false);
|
|
215
|
+
return _session != null;
|
|
216
|
+
}
|
|
217
|
+
catch (ImApiException)
|
|
218
|
+
{
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// <summary>退出登录(POST /client/v1/sessions/logout),同时断开当前游戏房间(不发 LEAVE)。</summary>
|
|
224
|
+
public async Task LogoutAsync(CancellationToken ct = default)
|
|
225
|
+
{
|
|
226
|
+
var current = _session;
|
|
227
|
+
_session = null;
|
|
228
|
+
SignedOut?.Invoke();
|
|
229
|
+
if (current == null) return;
|
|
230
|
+
SessionChanged?.Invoke(null);
|
|
231
|
+
if (string.IsNullOrEmpty(current.RefreshToken)) return;
|
|
232
|
+
await _http.SendAsync("POST", "/client/v1/sessions/logout",
|
|
233
|
+
new Dictionary<string, object> { ["refresh_token"] = current.RefreshToken }, false, ct).ConfigureAwait(false);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private ImSession SetSession(object res, string username)
|
|
237
|
+
{
|
|
238
|
+
var access = Json.Str(res, "access_token");
|
|
239
|
+
if (string.IsNullOrEmpty(access)) throw new ImApiException(200, "invalid_response", "登录返回缺少 access_token", null, null, null);
|
|
240
|
+
var s = new ImSession
|
|
241
|
+
{
|
|
242
|
+
AccessToken = access,
|
|
243
|
+
RefreshToken = Json.Str(res, "refresh_token"),
|
|
244
|
+
SessionId = Json.Str(res, "session_id"),
|
|
245
|
+
Username = Json.Str(Json.Obj(res, "user"), "username") ?? username,
|
|
246
|
+
};
|
|
247
|
+
_session = s;
|
|
248
|
+
SessionChanged?.Invoke(s);
|
|
249
|
+
return s;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// <summary>C1 的返回(《04-加入与对局/后端接口设计》第四节)。</summary>
|
|
254
|
+
internal sealed class JoinResponse
|
|
255
|
+
{
|
|
256
|
+
public string RoomId;
|
|
257
|
+
public string SyncMode;
|
|
258
|
+
public int TickRate;
|
|
259
|
+
public int MaxPlayers;
|
|
260
|
+
public int ReconnectWindowSeconds;
|
|
261
|
+
public int Seat;
|
|
262
|
+
public string NodeId;
|
|
263
|
+
public string UdpHost;
|
|
264
|
+
public int UdpPort;
|
|
265
|
+
public string WsUrl;
|
|
266
|
+
public string ConnectTicket;
|
|
267
|
+
public byte[] SessionKey;
|
|
268
|
+
public int ProtocolVersion;
|
|
269
|
+
|
|
270
|
+
public static JoinResponse Parse(object res)
|
|
271
|
+
{
|
|
272
|
+
var room = Json.Obj(res, "room");
|
|
273
|
+
var member = Json.Obj(res, "member");
|
|
274
|
+
var connect = Json.Obj(res, "connect");
|
|
275
|
+
if (room == null || connect == null) throw new ImApiException(200, "invalid_response", "加入接口的返回缺少 room / connect", null, null, null);
|
|
276
|
+
var udp = Json.Obj(connect, "udp");
|
|
277
|
+
var key = Json.Str(connect, "session_key");
|
|
278
|
+
byte[] sessionKey;
|
|
279
|
+
try
|
|
280
|
+
{
|
|
281
|
+
sessionKey = Convert.FromBase64String(key ?? "");
|
|
282
|
+
}
|
|
283
|
+
catch (FormatException)
|
|
284
|
+
{
|
|
285
|
+
sessionKey = Array.Empty<byte>();
|
|
286
|
+
}
|
|
287
|
+
if (sessionKey.Length != 32) throw new ImApiException(200, "invalid_response", "加入接口返回的会话密钥不是 32 字节", null, null, null);
|
|
288
|
+
return new JoinResponse
|
|
289
|
+
{
|
|
290
|
+
RoomId = Json.Str(room, "room_id"),
|
|
291
|
+
SyncMode = Json.Str(room, "sync_mode"),
|
|
292
|
+
TickRate = (int)(Json.Long(room, "tick_rate") ?? 0),
|
|
293
|
+
MaxPlayers = (int)(Json.Long(room, "max_players") ?? 0),
|
|
294
|
+
ReconnectWindowSeconds = (int)(Json.Long(room, "reconnect_window_seconds") ?? 0),
|
|
295
|
+
Seat = (int)(Json.Long(member, "seat") ?? 0),
|
|
296
|
+
NodeId = Json.Str(connect, "node_id"),
|
|
297
|
+
UdpHost = Json.Str(udp, "host"),
|
|
298
|
+
UdpPort = (int)(Json.Long(udp, "port") ?? 0),
|
|
299
|
+
WsUrl = Json.Str(connect, "ws_url"),
|
|
300
|
+
ConnectTicket = Json.Str(connect, "connect_ticket") ?? "",
|
|
301
|
+
SessionKey = sessionKey,
|
|
302
|
+
ProtocolVersion = (int)(Json.Long(connect, "protocol_version") ?? 1),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// <summary>游戏房间的客户端接口 C1–C3。网络错误按 1、2、4 秒退避重试 3 次。</summary>
|
|
308
|
+
internal sealed class GameHttpApi
|
|
309
|
+
{
|
|
310
|
+
private static readonly int[] RetryDelaysMs = { 1000, 2000, 4000 };
|
|
311
|
+
private readonly ImHttp _http;
|
|
312
|
+
private readonly Logger _log;
|
|
313
|
+
|
|
314
|
+
public GameHttpApi(ImHttp http, Logger log)
|
|
315
|
+
{
|
|
316
|
+
_http = http;
|
|
317
|
+
_log = log;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private static string RoomPath(string roomId) => "/client/v1/game/rooms/" + Uri.EscapeDataString(roomId);
|
|
321
|
+
|
|
322
|
+
public async Task<JoinResponse> JoinAsync(string roomId, string clientSessionId, string ticket, GameTransport transport, CancellationToken ct)
|
|
323
|
+
{
|
|
324
|
+
var body = new Dictionary<string, object>
|
|
325
|
+
{
|
|
326
|
+
["client_session_id"] = clientSessionId,
|
|
327
|
+
["transport"] = transport == GameTransport.Udp ? "udp" : transport == GameTransport.Ws ? "ws" : "auto",
|
|
328
|
+
};
|
|
329
|
+
if (!string.IsNullOrEmpty(ticket)) body["ticket"] = ticket;
|
|
330
|
+
var res = await RetryAsync(() => _http.SendAsync("POST", RoomPath(roomId) + "/join", body, true, ct), ct).ConfigureAwait(false);
|
|
331
|
+
return JoinResponse.Parse(res);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
public Task LeaveAsync(string roomId, CancellationToken ct) =>
|
|
335
|
+
RetryAsync(() => _http.SendAsync("POST", RoomPath(roomId) + "/leave", null, true, ct), ct);
|
|
336
|
+
|
|
337
|
+
public async Task<GameCurrent> CurrentAsync(CancellationToken ct)
|
|
338
|
+
{
|
|
339
|
+
var res = await RetryAsync(() => _http.SendAsync("GET", "/client/v1/game/current", null, true, ct), ct).ConfigureAwait(false);
|
|
340
|
+
var room = Json.Obj(res, "room");
|
|
341
|
+
if (room == null) return null;
|
|
342
|
+
var member = Json.Obj(res, "member");
|
|
343
|
+
return new GameCurrent
|
|
344
|
+
{
|
|
345
|
+
RoomId = Json.Str(room, "room_id"),
|
|
346
|
+
RoomStatus = Json.Str(room, "status"),
|
|
347
|
+
Seat = (int)(Json.Long(member, "seat") ?? 0),
|
|
348
|
+
MemberStatus = Json.Str(member, "status"),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async Task<object> RetryAsync(Func<Task<object>> fn, CancellationToken ct)
|
|
353
|
+
{
|
|
354
|
+
for (int attempt = 0; ; attempt++)
|
|
355
|
+
{
|
|
356
|
+
try
|
|
357
|
+
{
|
|
358
|
+
return await fn().ConfigureAwait(false);
|
|
359
|
+
}
|
|
360
|
+
catch (ImNetworkException e) when (attempt < RetryDelaysMs.Length)
|
|
361
|
+
{
|
|
362
|
+
_log.Info($"游戏接口网络错误,{RetryDelaysMs[attempt]}ms 后重试:{e.Message}");
|
|
363
|
+
await Task.Delay(RetryDelaysMs[attempt], ct).ConfigureAwait(false);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "DeepRespond.Im.Game",
|
|
3
|
+
"rootNamespace": "DeepRespond.Im.Game",
|
|
4
|
+
"references": [],
|
|
5
|
+
"includePlatforms": [],
|
|
6
|
+
"excludePlatforms": ["WebGL"],
|
|
7
|
+
"allowUnsafeCode": false,
|
|
8
|
+
"overrideReferences": false,
|
|
9
|
+
"precompiledReferences": [],
|
|
10
|
+
"autoReferenced": true,
|
|
11
|
+
"defineConstraints": [],
|
|
12
|
+
"versionDefines": [],
|
|
13
|
+
"noEngineReferences": true
|
|
14
|
+
}
|