| New file |
| | |
| | | // ============================================================================ |
| | | // XingwanTTSDK.cs — 星玩(xingwan001)抖音小游戏 SDK 封装(AOT 程序集 Launch) |
| | | // |
| | | // 【为什么整个放在 Launch(AOT)】 |
| | | // WebGL 下 wasm 的导入函数由 IL2CPP / Emscripten 在 AOT 期静态解析,热更 DLL 里 |
| | | // 新增的 DllImport("__Internal") 运行时无法解析,会抛 DllNotFoundException; |
| | | // JS 回调所需的 MonoPInvokeCallback 反向包装也必须由 AOT 侧生成。 |
| | | // 因此 P/Invoke 与 SDK 封装一起放在 AOT,和抖音 TTSDK 的结构完全一致: |
| | | // TTSDK → Plugins/ByteGame/.../WebGL/TTWebGL.asmdef(AOT 独立程序集) |
| | | // 星玩 → Assets/Launch/SDK/XingwanTTSDK.cs(AOT 程序集 Launch) |
| | | // 热更侧 SDKDouyinImpl 只做普通方法调用,等同于 DouyinPlatform 调 TT.InitSDK。 |
| | | // |
| | | // |
| | | // 【不做平台宏裁剪】整个类始终编译(含 Editor)。 |
| | | // Editor 下 SDKDouyinImpl 仍会引用本类(SyncType / Enabled 等),裁掉会编译失败; |
| | | // 是否真的调 JS 由 Enabled(内含 IsJsRuntime)运行时判断,Editor 下天然为 false, |
| | | // 不会触及任何 extern,因此 DllImport 声明在 Editor 下无害。 |
| | | // ============================================================================ |
| | | |
| | | using System; |
| | | using System.Runtime.InteropServices; |
| | | using Cysharp.Threading.Tasks; |
| | | using LitJson; |
| | | using UnityEngine; |
| | | using UnityEngine.Scripting; |
| | | |
| | | /// <summary>星玩 JS 回调统一签名:回传一个 JSON 字符串。</summary> |
| | | public delegate void XingwanStringCallback(string json); |
| | | |
| | | /// <summary> |
| | | /// 星玩(xingwan001)抖音小游戏 SDK 的 C# 封装。 |
| | | /// 通过 Assets/Plugins/Xingwan/XingwanTTGameSdk.jslib 调用小游戏根目录下的 xingwanTTGameSdk.js。 |
| | | /// 对接文档:https://docs.xingwan001.com/client/ttDoc.html |
| | | /// |
| | | /// 生命周期:initGame(必须最先) -> login -> pay / syncData |
| | | /// 所有接口成功码固定为 20000(StatusCodeOk)。 |
| | | /// </summary> |
| | | public static class XingwanTTSDK |
| | | { |
| | | /// <summary>星玩返回的成功状态码。</summary> |
| | | public const int StatusCodeOk = 20000; |
| | | |
| | | private const int InitTimeoutMs = 20000; |
| | | private const int LoginTimeoutMs = 20000; |
| | | private const int SyncTimeoutMs = 15000; |
| | | |
| | | // ================================================================ |
| | | // 渠道参数:由星玩后台下发,直接写死在这里 |
| | | // 两个值都非空才启用星玩渠道,否则 SDKDouyinImpl 拒绝登录/支付 |
| | | // ================================================================ |
| | | |
| | | /// <summary>渠道游戏标识 accessKeyId(星玩后台下发)。</summary> |
| | | public static string AccessKeyId = "dasv8gr9ao6f4ecjlijg"; |
| | | |
| | | /// <summary>渠道客户端公钥 clientKeySecret(注意不是 ServerKeySecret,后者只给服务端用)。</summary> |
| | | public static string ClientKeySecret = "0a707656d0b4ba5a473e4c250e9e25951823261411861c28d346ba2306bb9603"; |
| | | |
| | | /// <summary>两个渠道参数都填了。只代表配置层面已接入,不代表当前环境能调 JS。</summary> |
| | | public static bool Configured |
| | | { |
| | | get { return !string.IsNullOrEmpty(AccessKeyId) && !string.IsNullOrEmpty(ClientKeySecret); } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 当前是否真的能调用 jslib。 |
| | | /// |
| | | /// 必须用运行时判断,不能依赖 UNITY_EDITOR 这类编译期宏:本程序集在 Editor 下同样会编译, |
| | | /// 若只靠宏判断,Editor 里也会走进星玩通道去调不存在的 JS 而抛 DllNotFoundException。 |
| | | /// </summary> |
| | | public static bool IsJsRuntime |
| | | { |
| | | get { return Application.platform == RuntimePlatform.WebGLPlayer; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 星玩是否启用:参数已配置,且当前确实运行在 WebGL(真机或小游戏开发者工具)。 |
| | | /// 为 false 时 SDKDouyinImpl 走原有登录/支付路径(Editor 下即平台模拟实现)。 |
| | | /// </summary> |
| | | public static bool Enabled |
| | | { |
| | | get { return Configured && IsJsRuntime; } |
| | | } |
| | | |
| | | // ================================================================ |
| | | // jslib 声明:Assets/Plugins/Xingwan/XingwanTTGameSdk.jslib |
| | | // ================================================================ |
| | | |
| | | [DllImport("__Internal")] |
| | | private static extern void Xingwan_Init(string accessKeyId, string clientKeySecret, XingwanStringCallback cb); |
| | | |
| | | [DllImport("__Internal")] |
| | | private static extern void Xingwan_Login(XingwanStringCallback cb); |
| | | |
| | | [DllImport("__Internal")] |
| | | private static extern void Xingwan_Pay(string payJson, XingwanStringCallback cb); |
| | | |
| | | [DllImport("__Internal")] |
| | | private static extern void Xingwan_SyncData(string syncJson, XingwanStringCallback cb); |
| | | |
| | | [DllImport("__Internal")] |
| | | private static extern int Xingwan_IsReady(); |
| | | |
| | | // ================================================================ |
| | | // 反向 P/Invoke 包装:IL2CPP 为这些静态方法生成函数指针,jslib 才能 dynCall 回来 |
| | | // ================================================================ |
| | | |
| | | [Preserve] |
| | | [AOT.MonoPInvokeCallback(typeof(XingwanStringCallback))] |
| | | private static void OnInitResultImpl(string json) |
| | | { |
| | | SettleInit(Parse(json)); |
| | | } |
| | | |
| | | [Preserve] |
| | | [AOT.MonoPInvokeCallback(typeof(XingwanStringCallback))] |
| | | private static void OnLoginResultImpl(string json) |
| | | { |
| | | s_loginTcs?.TrySetResult(Parse(json)); |
| | | } |
| | | |
| | | [Preserve] |
| | | [AOT.MonoPInvokeCallback(typeof(XingwanStringCallback))] |
| | | private static void OnPayResultImpl(string json) |
| | | { |
| | | s_payTcs?.TrySetResult(Parse(json)); |
| | | } |
| | | |
| | | [Preserve] |
| | | [AOT.MonoPInvokeCallback(typeof(XingwanStringCallback))] |
| | | private static void OnSyncResultImpl(string json) |
| | | { |
| | | s_syncTcs?.TrySetResult(Parse(json)); |
| | | } |
| | | |
| | | // ================================================================ |
| | | // 结果定义 |
| | | // ================================================================ |
| | | |
| | | public class XingwanResult |
| | | { |
| | | public bool Ok; |
| | | public int StatusCode; |
| | | public string Status; |
| | | public JsonData Data; |
| | | |
| | | public override string ToString() |
| | | { |
| | | return $"Ok={Ok}, statusCode={StatusCode}, status={Status}"; |
| | | } |
| | | } |
| | | |
| | | public class XingwanLoginResult : XingwanResult |
| | | { |
| | | /// <summary>登录 token,需传给游戏服务端做校验(服务端用 ServerKeySecret 向星玩验签)。</summary> |
| | | public string Token; |
| | | /// <summary>星玩平台用户唯一标识 userId。</summary> |
| | | public string UserId; |
| | | } |
| | | |
| | | /// <summary>星玩 pay 接口参数(见文档 3.3)。</summary> |
| | | public class PayParams |
| | | { |
| | | /// <summary>支付金额,单位:分。</summary> |
| | | public int MoneyFen; |
| | | /// <summary>开发者支付订单号(CP 侧唯一)。</summary> |
| | | public string DeveloperOrder = string.Empty; |
| | | public string ProductId = string.Empty; |
| | | public string ProductName = string.Empty; |
| | | public string ZoneId = string.Empty; |
| | | public string ZoneName = string.Empty; |
| | | public string RoleId = string.Empty; |
| | | public string RoleName = string.Empty; |
| | | public string RoleLevel = string.Empty; |
| | | /// <summary>扩展参数,支付成功后原样回传给服务端。</summary> |
| | | public string DeveloperPayload = string.Empty; |
| | | } |
| | | |
| | | /// <summary>星玩 syncData 同步类型(见文档 3.4)。</summary> |
| | | public static class SyncType |
| | | { |
| | | public const int CreateRole = 1; |
| | | public const int EnterGame = 2; |
| | | public const int LevelUp = 3; |
| | | public const int ExitGame = 4; |
| | | } |
| | | |
| | | // ================================================================ |
| | | // 对外接口 |
| | | // ================================================================ |
| | | |
| | | /// <summary>初始化星玩 SDK,登录/支付前必须成功。可重复调用,已成功则直接返回。</summary> |
| | | public static async UniTask<XingwanResult> InitAsync() |
| | | { |
| | | if (!Enabled) |
| | | return Fail("未配置星玩 accessKeyId / clientKeySecret,或当前非 WebGL 运行环境", -4); |
| | | |
| | | if (s_initResult != null && s_initResult.Ok) |
| | | return s_initResult; |
| | | |
| | | if (s_initTcs != null) |
| | | return await s_initTcs.Task; |
| | | |
| | | s_initTcs = new UniTaskCompletionSource<XingwanResult>(); |
| | | try |
| | | { |
| | | Xingwan_Init(AccessKeyId, ClientKeySecret, OnInitResultImpl); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | return SettleInit(Fail("initGame 调用异常: " + e.Message, -1)); |
| | | } |
| | | |
| | | var (hasResult, result) = await UniTask.WhenAny(s_initTcs.Task, UniTask.Delay(InitTimeoutMs)); |
| | | if (!hasResult) |
| | | result = SettleInit(Fail("星玩初始化超时", -5)); |
| | | |
| | | if (!result.Ok) |
| | | Debug.LogError($"[Xingwan] 初始化失败: {result}"); |
| | | else |
| | | Debug.Log($"[Xingwan] 初始化成功: {result.Status}"); |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /// <summary>确保初始化完成;未手动初始化时内部自动触发一次。</summary> |
| | | public static async UniTask<XingwanResult> EnsureInitAsync() |
| | | { |
| | | if (s_initResult != null && s_initResult.Ok) |
| | | return s_initResult; |
| | | |
| | | if (s_initTcs == null) |
| | | return await InitAsync(); |
| | | |
| | | return await s_initTcs.Task; |
| | | } |
| | | |
| | | /// <summary>星玩登录,返回 token 与 userId。</summary> |
| | | public static async UniTask<XingwanLoginResult> LoginAsync() |
| | | { |
| | | var init = await EnsureInitAsync(); |
| | | if (!init.Ok) |
| | | return new XingwanLoginResult |
| | | { |
| | | Ok = false, |
| | | StatusCode = init.StatusCode, |
| | | Status = "星玩未初始化: " + init.Status |
| | | }; |
| | | |
| | | if (s_loginTcs != null) |
| | | { |
| | | Debug.LogWarning("[Xingwan] 上一次登录尚未结束,复用其结果"); |
| | | return ToLoginResult(await s_loginTcs.Task); |
| | | } |
| | | |
| | | s_loginTcs = new UniTaskCompletionSource<XingwanResult>(); |
| | | try |
| | | { |
| | | Xingwan_Login(OnLoginResultImpl); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | s_loginTcs.TrySetResult(Fail("login 调用异常: " + e.Message, -1)); |
| | | } |
| | | |
| | | var (hasResult, result) = await UniTask.WhenAny(s_loginTcs.Task, UniTask.Delay(LoginTimeoutMs)); |
| | | s_loginTcs = null; |
| | | |
| | | if (!hasResult) |
| | | result = Fail("星玩登录超时", -5); |
| | | |
| | | var login = ToLoginResult(result); |
| | | if (!login.Ok) |
| | | Debug.LogError($"[Xingwan] 登录失败: {login}"); |
| | | else |
| | | Debug.Log($"[Xingwan] 登录成功 userId={login.UserId}, token长度={login.Token?.Length ?? 0}"); |
| | | |
| | | return login; |
| | | } |
| | | |
| | | /// <summary>发起星玩支付。返回 Ok 表示支付流程完成(服务端以回调为准)。</summary> |
| | | public static async UniTask<XingwanResult> PayAsync(PayParams p) |
| | | { |
| | | if (p == null) |
| | | return Fail("支付参数为空", -4); |
| | | |
| | | var init = await EnsureInitAsync(); |
| | | if (!init.Ok) |
| | | return Fail("星玩未初始化: " + init.Status, init.StatusCode); |
| | | |
| | | if (string.IsNullOrEmpty(p.DeveloperOrder)) |
| | | return Fail("开发者订单号为空", -4); |
| | | |
| | | var json = new JsonData(); |
| | | json["money"] = p.MoneyFen; |
| | | json["developerOrder"] = p.DeveloperOrder ?? string.Empty; |
| | | json["productId"] = p.ProductId ?? string.Empty; |
| | | json["productName"] = p.ProductName ?? string.Empty; |
| | | json["zoneId"] = p.ZoneId ?? string.Empty; |
| | | json["zoneName"] = p.ZoneName ?? string.Empty; |
| | | json["roleId"] = p.RoleId ?? string.Empty; |
| | | json["roleName"] = p.RoleName ?? string.Empty; |
| | | json["roleLevel"] = p.RoleLevel ?? string.Empty; |
| | | json["developerPayload"] = p.DeveloperPayload ?? string.Empty; |
| | | |
| | | // 支付要等用户操作完成,不设超时 |
| | | s_payTcs = new UniTaskCompletionSource<XingwanResult>(); |
| | | try |
| | | { |
| | | Xingwan_Pay(json.ToJson(), OnPayResultImpl); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | s_payTcs.TrySetResult(Fail("pay 调用异常: " + e.Message, -1)); |
| | | } |
| | | |
| | | var result = await s_payTcs.Task; |
| | | s_payTcs = null; |
| | | |
| | | Debug.Log($"[Xingwan] 支付返回: {result}, order={p.DeveloperOrder}, money={p.MoneyFen}分"); |
| | | return result; |
| | | } |
| | | |
| | | /// <summary>提交角色数据(创建角色/进入游戏/升级/退出游戏)。type 见 SyncType。</summary> |
| | | public static async UniTask<XingwanResult> SyncDataAsync(int type, string zoneId, string zoneName, |
| | | string roleId, string roleName, string roleLevel) |
| | | { |
| | | var init = await EnsureInitAsync(); |
| | | if (!init.Ok) |
| | | return Fail("星玩未初始化: " + init.Status, init.StatusCode); |
| | | |
| | | var json = new JsonData(); |
| | | json["type"] = type; |
| | | json["zoneId"] = zoneId ?? string.Empty; |
| | | json["zoneName"] = zoneName ?? string.Empty; |
| | | json["roleId"] = roleId ?? string.Empty; |
| | | json["roleName"] = roleName ?? string.Empty; |
| | | json["roleLevel"] = roleLevel ?? string.Empty; |
| | | |
| | | s_syncTcs = new UniTaskCompletionSource<XingwanResult>(); |
| | | try |
| | | { |
| | | Xingwan_SyncData(json.ToJson(), OnSyncResultImpl); |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | s_syncTcs.TrySetResult(Fail("syncData 调用异常: " + e.Message, -1)); |
| | | } |
| | | |
| | | var (hasResult, result) = await UniTask.WhenAny(s_syncTcs.Task, UniTask.Delay(SyncTimeoutMs)); |
| | | s_syncTcs = null; |
| | | |
| | | if (!hasResult) |
| | | result = Fail("syncData 超时", -5); |
| | | |
| | | if (!result.Ok) |
| | | Debug.LogWarning($"[Xingwan] 角色数据同步失败 type={type}: {result}"); |
| | | else |
| | | Debug.Log($"[Xingwan] 角色数据同步成功 type={type}"); |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /// <summary>JS 层 SDK 是否已就绪(用于启动自检)。</summary> |
| | | public static bool IsJsReady() |
| | | { |
| | | if (!IsJsRuntime) |
| | | return false; |
| | | |
| | | try |
| | | { |
| | | return Xingwan_IsReady() == 1; |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | Debug.LogWarning($"[Xingwan] JS 就绪检查异常: {e.Message}"); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // ================================================================ |
| | | // 回调与解析 |
| | | // ================================================================ |
| | | |
| | | private static UniTaskCompletionSource<XingwanResult> s_initTcs; |
| | | private static UniTaskCompletionSource<XingwanResult> s_loginTcs; |
| | | private static UniTaskCompletionSource<XingwanResult> s_payTcs; |
| | | private static UniTaskCompletionSource<XingwanResult> s_syncTcs; |
| | | private static XingwanResult s_initResult; |
| | | |
| | | private static XingwanResult SettleInit(XingwanResult result) |
| | | { |
| | | s_initResult = result; |
| | | s_initTcs?.TrySetResult(result); |
| | | // 初始化失败/超时后清空凭证,允许下次调用重试 |
| | | if (!result.Ok) |
| | | s_initTcs = null; |
| | | return result; |
| | | } |
| | | |
| | | private static XingwanResult Parse(string json) |
| | | { |
| | | var result = new XingwanResult(); |
| | | if (string.IsNullOrEmpty(json)) |
| | | { |
| | | result.StatusCode = -1; |
| | | result.Status = "空回调"; |
| | | return result; |
| | | } |
| | | |
| | | try |
| | | { |
| | | var data = JsonMapper.ToObject(json); |
| | | if (data == null) |
| | | return Fail("回调解析为空", -1); |
| | | |
| | | if (data.Keys.Contains("statusCode")) |
| | | result.StatusCode = ReadInt(data["statusCode"]); |
| | | if (data.Keys.Contains("status")) |
| | | result.Status = data["status"].ToString(); |
| | | if (data.Keys.Contains("data") && data["data"] != null) |
| | | result.Data = data["data"]; |
| | | |
| | | result.Ok = result.StatusCode == StatusCodeOk; |
| | | return result; |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | Debug.LogError($"[Xingwan] 回调解析异常: {e.Message}, json={json}"); |
| | | return Fail("回调解析异常: " + e.Message, -1); |
| | | } |
| | | } |
| | | |
| | | private static XingwanLoginResult ToLoginResult(XingwanResult result) |
| | | { |
| | | var login = new XingwanLoginResult |
| | | { |
| | | Ok = result.Ok, |
| | | StatusCode = result.StatusCode, |
| | | Status = result.Status, |
| | | Data = result.Data, |
| | | }; |
| | | |
| | | if (result.Data != null) |
| | | { |
| | | login.Token = ReadString(result.Data, "token"); |
| | | login.UserId = ReadString(result.Data, "userId"); |
| | | if (string.IsNullOrEmpty(login.UserId)) |
| | | login.UserId = ReadString(result.Data, "uid"); |
| | | if (string.IsNullOrEmpty(login.UserId)) |
| | | login.UserId = ReadString(result.Data, "user_id"); |
| | | if (string.IsNullOrEmpty(login.UserId)) |
| | | login.UserId = ReadString(result.Data, "openid"); |
| | | } |
| | | |
| | | return login; |
| | | } |
| | | |
| | | private static int ReadInt(JsonData data) |
| | | { |
| | | int value; |
| | | return int.TryParse(data.ToString(), out value) ? value : -1; |
| | | } |
| | | |
| | | private static string ReadString(JsonData data, string key) |
| | | { |
| | | if (data == null || !data.IsObject || !data.Keys.Contains(key)) |
| | | return string.Empty; |
| | | |
| | | var value = data[key]; |
| | | return value == null ? string.Empty : value.ToString(); |
| | | } |
| | | |
| | | private static XingwanResult Fail(string message, int code) |
| | | { |
| | | return new XingwanResult { Ok = false, StatusCode = code, Status = message }; |
| | | } |
| | | } |