GoogleH5Impl.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import AppConfig from "@scripts/app/AppConfig";
  2. import { Camera, Canvas, Node, screen, UITransform, Vec3 } from "cc";
  3. import { GoogleImpl, IGoogleBtnLogin, IGoogleLoginRes } from "./GoogleImpl";
  4. interface GoogleCredentialResponse {
  5. credential?: string;
  6. select_by?: string;
  7. }
  8. interface GoogleIdentityService {
  9. initialize: (config: {
  10. client_id: string;
  11. callback: (response: GoogleCredentialResponse) => void;
  12. ux_mode?: "popup" | "redirect";
  13. }) => void;
  14. renderButton: (parent: HTMLElement, options: {
  15. type?: "standard" | "icon";
  16. theme?: "outline" | "filled_blue" | "filled_black";
  17. size?: "large" | "medium" | "small";
  18. text?: "signin_with" | "signup_with" | "continue_with" | "signin";
  19. shape?: "rectangular" | "pill" | "circle" | "square";
  20. logo_alignment?: "left" | "center";
  21. width?: number;
  22. }) => void;
  23. disableAutoSelect: () => void;
  24. }
  25. interface GoogleSdkWindow extends Window {
  26. google?: {
  27. accounts?: {
  28. id?: GoogleIdentityService;
  29. };
  30. };
  31. }
  32. const GOOGLE_SDK_ID = "google-identity-services";
  33. const GOOGLE_SDK_URL = "https://accounts.google.com/gsi/client";
  34. export class GoogleH5Impl extends GoogleImpl {
  35. private _initPromise: Promise<void> | null = null;
  36. private _loginCallback: (res: IGoogleLoginRes) => void;
  37. private _buttonSeed = 0;
  38. init(): Promise<void> {
  39. if (this.isInitialized) return Promise.resolve();
  40. if (this._initPromise) return this._initPromise;
  41. this._initPromise = this.loadGoogleSdk()
  42. .then(() => {
  43. const googleIdentity = this.getGoogleIdentity();
  44. if (!googleIdentity) {
  45. throw new Error("Google Identity Services 初始化失败");
  46. }
  47. googleIdentity.initialize({
  48. client_id: AppConfig.GoogleID,
  49. ux_mode: "popup",
  50. callback: response => this.handleCredentialResponse(response),
  51. });
  52. this.onInited();
  53. console.log(googleIdentity);
  54. })
  55. .catch(error => {
  56. this._initPromise = null;
  57. throw error;
  58. });
  59. return this._initPromise;
  60. }
  61. protected async onLogin(): Promise<IGoogleLoginRes> {
  62. return null;
  63. }
  64. async logOut(): Promise<boolean> {
  65. return true;
  66. }
  67. createLoginBtn(node: Node, callback: (res: IGoogleLoginRes) => void): IGoogleBtnLogin {
  68. const container = document.createElement("div");
  69. container.id = `google-login-button-${++this._buttonSeed}`;
  70. container.style.position = "fixed";
  71. container.style.zIndex = "9999";
  72. container.style.display = "flex";
  73. container.style.alignItems = "center";
  74. container.style.justifyContent = "center";
  75. container.style.overflow = "hidden";
  76. container.style.boxSizing = "border-box";
  77. container.style.opacity = "0";
  78. document.body.appendChild(container);
  79. let active = node.activeInHierarchy;
  80. let cleared = false;
  81. let animationFrameId = 0;
  82. this._loginCallback = callback;
  83. const clear = () => {
  84. if (cleared) return;
  85. cleared = true;
  86. window.cancelAnimationFrame(animationFrameId);
  87. container.remove();
  88. };
  89. const update = () => {
  90. if (cleared) return;
  91. if (!node.isValid) {
  92. clear();
  93. return;
  94. }
  95. this.syncDomLayout(container, node, active);
  96. animationFrameId = window.requestAnimationFrame(update);
  97. };
  98. this.syncDomLayout(container, node, active);
  99. animationFrameId = window.requestAnimationFrame(update);
  100. this.init()
  101. .then(() => {
  102. if (cleared || !node.isValid) return;
  103. this.syncDomLayout(container, node, active);
  104. const containerWidth = Math.round(container.getBoundingClientRect().width) || 280;
  105. const width = Math.max(40, Math.min(400, containerWidth));
  106. this.getGoogleIdentity()?.renderButton(container, {
  107. type: "standard",
  108. theme: "outline",
  109. size: "large",
  110. text: "signin_with",
  111. shape: "rectangular",
  112. logo_alignment: "left",
  113. width,
  114. });
  115. })
  116. .catch(error => {
  117. console.error("[Google] 登录按钮创建失败", error);
  118. clear();
  119. });
  120. return {
  121. active,
  122. setActive: (visible: boolean) => {
  123. if (cleared) return;
  124. if (!node.isValid) {
  125. clear();
  126. return;
  127. }
  128. active = visible;
  129. this.syncDomLayout(container, node, active);
  130. },
  131. clear,
  132. };
  133. }
  134. private handleCredentialResponse(response: GoogleCredentialResponse): void {
  135. console.log('[Google] handleCredentialResponse:', response, !!this._loginCallback)
  136. const credential = response?.credential || "";
  137. const result: IGoogleLoginRes = {
  138. // GIS 返回的是 JWT ID Token,沿用项目接口的 accessToken 字段传递。
  139. accessToken: credential,
  140. isCancel: !credential,
  141. status: credential ? "connected" : "failed",
  142. reason: credential ? undefined : "Google 登录未返回 credential",
  143. };
  144. this._loginCallback?.(result);
  145. }
  146. private getGoogleIdentity(): GoogleIdentityService | undefined {
  147. return (window as GoogleSdkWindow).google?.accounts?.id;
  148. }
  149. private loadGoogleSdk(): Promise<void> {
  150. if (this.getGoogleIdentity()) return Promise.resolve();
  151. return new Promise((resolve, reject) => {
  152. let script = document.getElementById(GOOGLE_SDK_ID) as HTMLScriptElement | null;
  153. if (!script) {
  154. script = document.createElement("script");
  155. script.id = GOOGLE_SDK_ID;
  156. script.src = GOOGLE_SDK_URL;
  157. script.async = true;
  158. script.defer = true;
  159. document.head.appendChild(script);
  160. }
  161. const clearListeners = () => {
  162. window.clearTimeout(timeoutId);
  163. script?.removeEventListener("load", onLoad);
  164. script?.removeEventListener("error", onError);
  165. };
  166. const onLoad = () => {
  167. clearListeners();
  168. this.getGoogleIdentity()
  169. ? resolve()
  170. : reject(new Error("Google Identity Services SDK 不可用"));
  171. };
  172. const onError = () => {
  173. clearListeners();
  174. script?.remove();
  175. reject(new Error("Google Identity Services SDK 下载失败"));
  176. };
  177. const timeoutId = window.setTimeout(() => {
  178. clearListeners();
  179. script?.remove();
  180. reject(new Error("Google Identity Services SDK 加载超时"));
  181. }, 15000);
  182. script.addEventListener("load", onLoad, { once: true });
  183. script.addEventListener("error", onError, { once: true });
  184. });
  185. }
  186. private syncDomLayout(container: HTMLDivElement, node: Node, active: boolean): void {
  187. if (!active || !node.activeInHierarchy) {
  188. container.style.display = "none";
  189. return;
  190. }
  191. const uiTransform = node.getComponent(UITransform);
  192. const camera = this.findCanvasCamera(node);
  193. const gameCanvas = this.getGameCanvas();
  194. if (!uiTransform || !camera || !gameCanvas) {
  195. container.style.display = "none";
  196. return;
  197. }
  198. const worldBounds = uiTransform.getBoundingBoxToWorld();
  199. const worldZ = node.worldPosition.z;
  200. const bottomLeft = camera.worldToScreen(new Vec3(worldBounds.xMin, worldBounds.yMin, worldZ));
  201. const topRight = camera.worldToScreen(new Vec3(worldBounds.xMax, worldBounds.yMax, worldZ));
  202. const screenWidth = screen.windowSize.width || gameCanvas.width;
  203. const screenHeight = screen.windowSize.height || gameCanvas.height;
  204. if (screenWidth <= 0 || screenHeight <= 0) {
  205. container.style.display = "none";
  206. return;
  207. }
  208. const canvasRect = gameCanvas.getBoundingClientRect();
  209. const minX = Math.min(bottomLeft.x, topRight.x);
  210. const maxX = Math.max(bottomLeft.x, topRight.x);
  211. const minY = Math.min(bottomLeft.y, topRight.y);
  212. const maxY = Math.max(bottomLeft.y, topRight.y);
  213. container.style.display = "flex";
  214. container.style.left = `${canvasRect.left + minX / screenWidth * canvasRect.width}px`;
  215. container.style.top = `${canvasRect.top + (1 - maxY / screenHeight) * canvasRect.height}px`;
  216. container.style.width = `${(maxX - minX) / screenWidth * canvasRect.width}px`;
  217. container.style.height = `${(maxY - minY) / screenHeight * canvasRect.height}px`;
  218. }
  219. private findCanvasCamera(node: Node): Camera | null {
  220. let current: Node | null = node;
  221. while (current) {
  222. const canvas = current.getComponent(Canvas);
  223. if (canvas) return canvas.cameraComponent || current.getComponent(Camera);
  224. current = current.parent;
  225. }
  226. return null;
  227. }
  228. private getGameCanvas(): HTMLCanvasElement | null {
  229. const gameCanvas = document.getElementById("GameCanvas");
  230. if (gameCanvas instanceof HTMLCanvasElement) return gameCanvas;
  231. return document.querySelector("canvas");
  232. }
  233. }