宿主桥
游戏页面运行在你 App 的 WebView 中,通过一个小型 JSBridge 与 App 通信。它沿用大多数语聊 App 已经实现的 BAISHUN(百顺)1.3.2 JSBridge 格式;如果你已经在接入其他 H5 游戏,很可能已经具备。
用游戏入口地址打开游戏(见游戏目录):
https://sandbox.sentosatower.com/games/<gameId>/index.html?op=<operatorId>https://games.sentosatower.com/games/<gameId>/index.html?op=<operatorId>App 需要提供的方法
Section titled “App 需要提供的方法”| 方法 | 页面调用方式 | App 应当 |
|---|---|---|
getConfig |
Android / Flutter:NativeBridge.getConfig(JSON.stringify({ jsCallback }))iOS: webkit.messageHandlers.getConfig.postMessage({ jsCallback }) |
申请一个新的启动码,然后调用 window[jsCallback]("<配置 JSON 字符串>") |
gameLoaded |
NativeBridge.gameLoaded() / messageHandlers.gameLoaded.postMessage({}) |
首帧就绪:关闭你的 loading |
gameRecharge |
同上 | 打开充值页(余额不足或玩家点击余额) |
destroy |
同上 | 关闭游戏并释放 WebView |
可选扩展——未实现时页面静默忽略。页面传入一个 JSON 字符串参数:
| 方法 | 参数 | 用途 |
|---|---|---|
haptic |
{"type":"light" | "success" | "heavy"} |
振动 |
roomEvent |
{"type":"bigwin","gameId","name","amount","roundId"} |
在房间内播报大奖 |
displayMode |
{"mode":"half" | "full","height"?} |
玩家切换了半屏 / 全屏(例如调整触摸穿透) |
App → 页面:充值成功后调用 window.walletUpdate({}),游戏会向平台刷新余额。
getConfig 字段
Section titled “getConfig 字段”| 字段 | 必填 | 说明 |
|---|---|---|
code |
是 | 启动码:由你的后端签发,不透明、一次性、有效期 ≤ 5 分钟。平台通过 POST /players/authenticate 兑换。 |
userId |
建议 | 你的玩家 ID(展示和排障用;身份以 authenticate 返回为准) |
roomId |
可选 | 当前语聊房,会作为交易的 room_id 带回 |
language |
建议 | ISO 语言码(en、ar、zh-CN 等)或百顺数字码 |
gameMode |
可选 | 2 半屏,3 全屏 |
safeHeight、safeHeightUnit |
可选 | 半屏可用高度;单位 css(CSS 像素)或 design750(750 宽设计稿像素) |
appId、appChannel、gameConfig、gsp |
可选 | 百顺兼容字段,接受但不使用 |
{ "code": "lc_7Qm2…", "userId": "84021734", "roomId": "8880085", "language": "ar", "gameMode": 2, "safeHeight": 420, "safeHeightUnit": "css" }- 桥要早于页面脚本。 如果你的 App 注入桥较晚(页面开始加载之后才注入,Flutter 和部分 Android 封装就是这样),请在入口地址后追加
uid=<userId>&time=<毫秒时间戳>。两个参数都存在时,页面会最多等待 3 秒让桥出现,而不是直接退回浏览器模式。 - getConfig 8 秒内应答。 如果
NativeBridge.getConfig同步返回非空 JSON 字符串,页面直接使用;否则等待jsCallback回调,最多 8 秒,超时显示进入失败。向你的后端申请启动码的耗时建议控制在 5 秒以内。 - 不要用旧脚本。 游戏脚本以
Cache-Control: no-cache和带版本号的地址提供,不要强制 WebView 缓存。 - 每次打开游戏都重新调用
getConfig:启动码只能用一次。
App 示例
Section titled “App 示例”class StarGameBridge(private val activity: Activity, private val webView: WebView) {
@JavascriptInterface fun getConfig(params: String?): String { val callback = params?.let { JSONObject(it).optString("jsCallback") }.orEmpty() // Ask your backend for a one-time launch code (off the UI thread). launchCodes.issue(currentUser.id) { code -> val config = JSONObject() .put("code", code) .put("userId", currentUser.id) .put("roomId", currentRoom.id) .put("language", Locale.getDefault().language) .put("gameMode", 2) .put("safeHeight", halfScreenHeightCssPx) .put("safeHeightUnit", "css") val args = JSONArray().put(callback).put(config.toString()) webView.post { webView.evaluateJavascript("(function(a){window[a[0]](a[1]);})($args)", null) } } return "" // empty: the page waits for the callback }
@JavascriptInterface fun gameLoaded() = activity.runOnUiThread { hideLoading() } @JavascriptInterface fun gameRecharge() = activity.runOnUiThread { openTopUp() } @JavascriptInterface fun destroy() = activity.runOnUiThread { closeGame() } @JavascriptInterface fun haptic(payload: String) { /* optional */ } @JavascriptInterface fun displayMode(payload: String) { /* optional */ }}
webView.settings.javaScriptEnabled = truewebView.settings.domStorageEnabled = truewebView.addJavascriptInterface(StarGameBridge(activity, webView), "NativeBridge")webView.loadUrl("https://sandbox.sentosatower.com/games/lantern/index.html?op=$operatorId")final class StarGameHost: NSObject, WKScriptMessageHandler { weak var webView: WKWebView?
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { switch message.name { case "getConfig": guard let body = message.body as? [String: Any], let callback = body["jsCallback"] as? String else { return } LaunchCodes.issue(for: Session.userId) { code in // one-time launch code from your backend let config: [String: Any] = ["code": code, "userId": Session.userId, "roomId": Room.current.id, "language": Locale.current.language.languageCode?.identifier ?? "en", "gameMode": 2, "safeHeight": 420, "safeHeightUnit": "css"] let json = String(data: try! JSONSerialization.data(withJSONObject: config), encoding: .utf8)! let args = String(data: try! JSONSerialization.data(withJSONObject: [callback, json]), encoding: .utf8)! DispatchQueue.main.async { self.webView?.evaluateJavaScript("(function(a){window[a[0]](a[1]);})(\(args))") } } case "gameLoaded": hideLoading() case "gameRecharge": openTopUp() case "destroy": closeGame() default: break // haptic, roomEvent, displayMode are optional } }}
let host = StarGameHost()let contentController = WKUserContentController()for name in ["getConfig", "gameLoaded", "gameRecharge", "destroy", "haptic", "roomEvent", "displayMode"] { contentController.add(host, name: name)}let configuration = WKWebViewConfiguration()configuration.userContentController = contentControllerlet webView = WKWebView(frame: .zero, configuration: configuration)host.webView = webViewwebView.load(URLRequest(url: URL(string: "https://sandbox.sentosatower.com/games/lantern/index.html?op=\(operatorId)")!))// A JavaScriptChannel is one-way and async, so inject a small NativeBridge shim that forwards to it.const bridgeShim = r'''(function () { if (window.NativeBridge) return; function send(method, params) { StarGameHost.postMessage(JSON.stringify({ method: method, params: params || "" })); } window.NativeBridge = { getConfig: function (p) { send("getConfig", p); return ""; }, gameLoaded: function () { send("gameLoaded"); }, gameRecharge: function () { send("gameRecharge"); }, destroy: function () { send("destroy"); }, haptic: function (p) { send("haptic", p); }, roomEvent: function (p) { send("roomEvent", p); }, displayMode: function (p) { send("displayMode", p); } };})();''';
late final WebViewController controller;
void openGame(String gameId) { final uid = session.userId; final url = 'https://sandbox.sentosatower.com/games/$gameId/index.html' '?op=$operatorId&uid=$uid&time=${DateTime.now().millisecondsSinceEpoch}'; // page waits up to 3 s for the bridge controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel('StarGameHost', onMessageReceived: (m) => onHostMessage(m.message)) ..setNavigationDelegate(NavigationDelegate(onPageStarted: (_) => controller.runJavaScript(bridgeShim))) ..loadRequest(Uri.parse(url));}
Future<void> onHostMessage(String raw) async { final msg = jsonDecode(raw) as Map<String, dynamic>; switch (msg['method']) { case 'getConfig': final callback = (jsonDecode(msg['params'] as String) as Map<String, dynamic>)['jsCallback'] as String; final code = await launchCodes.issue(session.userId); // one-time launch code from your backend final config = jsonEncode({'code': code, 'userId': session.userId, 'roomId': room.id, 'language': 'en', 'gameMode': 2, 'safeHeight': 420, 'safeHeightUnit': 'css'}); await controller.runJavaScript('(function(a){window[a[0]](a[1]);})(${jsonEncode([callback, config])});'); case 'gameLoaded': hideLoading(); case 'gameRecharge': openTopUp(); case 'destroy': closeGame(); }}在桌面浏览器中自测
Section titled “在桌面浏览器中自测”不用 App 也可以验证你的启动码。在入口地址后追加 &uid=1&time=1 打开,并在页面脚本运行前注入下面的代码(例如用设为 document-start 执行的用户脚本管理器):
window.NativeBridge = { getConfig(p) { const cb = JSON.parse(p).jsCallback; setTimeout(() => window[cb](JSON.stringify({ code: "<launch code>", userId: "<player id>", gameMode: 2, language: "en" })), 20); return ""; }, gameLoaded() { console.log("host: gameLoaded"); }, gameRecharge() { console.log("host: gameRecharge"); }, destroy() { console.log("host: destroy"); },};也可以用 Playwright 自动化:
import { chromium } from "playwright";
const browser = await chromium.launch();const page = await browser.newPage({ viewport: { width: 390, height: 844 } });page.on("console", (m) => console.log(m.text()));await page.addInitScript((code) => { window.NativeBridge = { getConfig(p) { const cb = JSON.parse(p).jsCallback; setTimeout(() => window[cb](JSON.stringify({ code, userId: "self-test", gameMode: 2, language: "en" })), 20); return ""; }, gameLoaded() { console.log("host: gameLoaded"); }, gameRecharge() {}, destroy() {}, };}, process.env.LAUNCH_CODE);await page.goto(`https://sandbox.sentosatower.com/games/lantern/index.html?op=${process.env.OPERATOR_ID}&uid=1&time=1`);await page.waitForTimeout(5000);await page.screenshot({ path: "lantern.png" });await browser.close();预期:约 3 秒内出现下注界面,控制台打印 host: gameLoaded,你的钱包收到一次针对该启动码的 POST /players/authenticate。