/** * Nakama WebSocket Manager - 小程序直连版 * 移除 SDK 依赖,直接使用原生 WebSocket 协议 */ class NakamaManager { constructor() { this.serverUrl = null; this.serverKey = 'defaultkey'; this.useSSL = true; this.host = null; this.port = '443'; this.session = null; this.gameToken = null; this.socketTask = null; this.isConnected = false; // 消息 ID 和待处理的 Promise this.nextCid = 1; this.pendingRequests = {}; // 事件监听器 this.listeners = { onmatchmakermatched: null, onmatchdata: null, onmatchpresence: null, ondisconnect: null, onerror: null }; // 心跳定时器 this.heartbeatTimer = null; this.heartbeatInterval = 10000; // 10秒 } /** * 初始化客户端配置 */ initClient(serverUrl, serverKey = 'defaultkey') { this.serverKey = serverKey; this.serverUrl = serverUrl; // 解析 URL const isWss = serverUrl.startsWith('wss://') || serverUrl.startsWith('https://'); let host = serverUrl.replace('wss://', '').replace('ws://', '').replace('https://', '').replace('http://', ''); let port = isWss ? '443' : '7350'; if (host.includes(':')) { const parts = host.split(':'); host = parts[0]; port = parts[1]; } this.host = host; this.port = port; this.useSSL = isWss; console.log(`[Nakama] Initialized: ${this.useSSL ? 'wss' : 'ws'}://${this.host}:${this.port}`); } /** * 设置事件监听器 */ setListeners(config) { Object.keys(config).forEach(key => { if (this.listeners.hasOwnProperty(key)) { this.listeners[key] = config[key]; } }); } /** * 使用 game_token 认证 */ async authenticateWithGameToken(gameToken) { this.gameToken = gameToken; // 生成唯一的 custom ID const customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`; console.log('[Nakama] Authenticating with Custom ID:', customId); // HTTP 认证请求 const scheme = this.useSSL ? 'https://' : 'http://'; const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`; const authUrl = `${scheme}${this.host}${portSuffix}/v2/account/authenticate/custom?create=true`; return new Promise((resolve, reject) => { uni.request({ url: authUrl, method: 'POST', header: { 'Authorization': 'Basic ' + this._base64Encode(`${this.serverKey}:`), 'Content-Type': 'application/json' }, data: { id: customId }, success: (res) => { if (res.statusCode === 200 && res.data && res.data.token) { this.session = { token: res.data.token, refresh_token: res.data.refresh_token, user_id: this._parseUserIdFromToken(res.data.token) }; console.log('[Nakama] Authenticated, user_id:', this.session.user_id); // 认证成功后建立 WebSocket 连接 this._connectWebSocket() .then(() => resolve(this.session)) .catch(reject); } else { reject(new Error('Authentication failed: ' + JSON.stringify(res.data))); } }, fail: (err) => { reject(new Error('Authentication request failed: ' + err.errMsg)); } }); }); } /** * 建立 WebSocket 连接 */ _connectWebSocket() { return new Promise((resolve, reject) => { const scheme = this.useSSL ? 'wss://' : 'ws://'; const portSuffix = (this.useSSL && this.port === '443') || (!this.useSSL && this.port === '80') ? '' : `:${this.port}`; const wsUrl = `${scheme}${this.host}${portSuffix}/ws?lang=en&status=true&token=${encodeURIComponent(this.session.token)}`; console.log('[Nakama] WebSocket connecting...'); this.socketTask = uni.connectSocket({ url: wsUrl, complete: () => { } }); const connectTimeout = setTimeout(() => { reject(new Error('WebSocket connection timeout')); }, 15000); this.socketTask.onOpen(() => { clearTimeout(connectTimeout); this.isConnected = true; console.log('[Nakama] WebSocket connected'); this._startHeartbeat(); resolve(); }); this.socketTask.onClose((res) => { console.log('[Nakama] WebSocket closed:', res.code, res.reason); this.isConnected = false; this._stopHeartbeat(); if (this.listeners.ondisconnect) { this.listeners.ondisconnect(res); } }); this.socketTask.onError((err) => { clearTimeout(connectTimeout); console.error('[Nakama] WebSocket error:', err); this.isConnected = false; if (this.listeners.onerror) { this.listeners.onerror(err); } reject(new Error('WebSocket connection failed')); }); this.socketTask.onMessage((res) => { this._handleMessage(res.data); }); }); } /** * 处理收到的消息 */ _handleMessage(rawData) { let message; try { message = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; } catch (e) { console.error('[Nakama] Failed to parse message:', e); return; } // 有 cid 的消息是请求的响应 if (message.cid) { const pending = this.pendingRequests[message.cid]; if (pending) { delete this.pendingRequests[message.cid]; clearTimeout(pending.timeout); if (message.error) { pending.reject(new Error(message.error.message || JSON.stringify(message.error))); } else { pending.resolve(message); } } return; } // 无 cid 的消息是服务器主动推送 if (message.matchmaker_matched) { console.log('[Nakama] Matchmaker matched:', message.matchmaker_matched.match_id); if (this.listeners.onmatchmakermatched) { this.listeners.onmatchmakermatched(message.matchmaker_matched); } } else if (message.match_data) { // 解码 base64 数据 if (message.match_data.data) { // 原生 Base64 -> Unit8Array const uint8arr = this._base64ToUint8Array(message.match_data.data); // !!!关键修复:移除不可用的 TextDecoder,直接传输 Unit8Array,或在业务层处理 // 由于 play.vue 中还在使用 TextDecoder,这里我们需要提供一个方法来转字符串 // 为了兼容性,我们直接在这里转成字符串传出去,修改 onmatchdata 的约定 message.match_data.data = this._utf8Decode(uint8arr); } message.match_data.op_code = parseInt(message.match_data.op_code); if (this.listeners.onmatchdata) { this.listeners.onmatchdata(message.match_data); } } else if (message.match_presence_event) { if (this.listeners.onmatchpresence) { this.listeners.onmatchpresence(message.match_presence_event); } } } /** * 发送消息并等待响应 */ _send(message, timeoutMs = 10000) { return new Promise((resolve, reject) => { if (!this.isConnected || !this.socketTask) { reject(new Error('Socket not connected')); return; } const cid = String(this.nextCid++); message.cid = cid; const timeout = setTimeout(() => { delete this.pendingRequests[cid]; reject(new Error('Request timeout')); }, timeoutMs); this.pendingRequests[cid] = { resolve, reject, timeout }; this.socketTask.send({ data: JSON.stringify(message), fail: (err) => { delete this.pendingRequests[cid]; clearTimeout(timeout); reject(new Error('Send failed: ' + err.errMsg)); } }); }); } /** * 发送消息(无需响应) */ _sendNoResponse(message) { if (!this.isConnected || !this.socketTask) { console.error('[Nakama] Cannot send, not connected'); return; } this.socketTask.send({ data: JSON.stringify(message), fail: (err) => { console.error('[Nakama] Send failed:', err); } }); } /** * 开始匹配 */ async findMatch(minCount, maxCount) { if (!this.isConnected) { console.log('[Nakama] Not connected, reconnecting...'); await this.authenticateWithGameToken(this.gameToken); } if (!this.gameToken) { throw new Error('Missing game token in manager'); } console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount); const response = await this._send({ matchmaker_add: { min_count: minCount || 2, max_count: maxCount || 2, query: '+properties.game_token:*', string_properties: { game_token: this.gameToken } } }); console.log('[Nakama] Matchmaker ticket:', response.matchmaker_ticket); return response.matchmaker_ticket; } /** * 加入比赛 */ async joinMatch(matchId, token) { console.log('[Nakama] Joining match:', matchId); const join = { match_join: {} }; if (token) { join.match_join.token = token; } else { join.match_join.match_id = matchId; } // 关键:传递 game_token 用于服务端验证 join.match_join.metadata = { game_token: this.gameToken }; const response = await this._send(join); console.log('[Nakama] Joined match:', response.match?.match_id); return response.match; } /** * 调用 RPC */ async rpc(id, payload) { console.log('[Nakama] RPC call:', id); const response = await this._send({ rpc: { id: id, payload: typeof payload === 'string' ? payload : JSON.stringify(payload) } }); if (response.rpc && response.rpc.payload) { try { return JSON.parse(response.rpc.payload); } catch (e) { return response.rpc.payload; } } return response; } /** * 发送游戏状态 */ sendMatchState(matchId, opCode, data) { const payload = typeof data === 'string' ? data : JSON.stringify(data); this._sendNoResponse({ match_data_send: { match_id: matchId, op_code: String(opCode), data: this._base64Encode(payload) } }); } /** * 断开连接 */ disconnect() { this._stopHeartbeat(); if (this.socketTask) { this.socketTask.close(); this.socketTask = null; } this.isConnected = false; this.session = null; // 注意:不要清空 gameToken,以便重连时仍然可以使用 // this.gameToken 只在 logout 或新 authenticate 时才会被更新 console.log('[Nakama] Disconnected'); } // ============ 心跳 ============ _startHeartbeat() { this._stopHeartbeat(); this.heartbeatTimer = setInterval(() => { if (this.isConnected) { this._send({ ping: {} }, 5000).catch(() => { console.warn('[Nakama] Heartbeat failed'); }); } }, this.heartbeatInterval); } _stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } // ============ 工具方法 ============ _base64Encode(str) { // 小程序环境没有 btoa,需要手动实现 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; let output = ''; // 将字符串转为 UTF-8 字节数组 const bytes = []; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code < 0x80) { bytes.push(code); } else if (code < 0x800) { bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)); } else { bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)); } } for (let i = 0; i < bytes.length; i += 3) { const b1 = bytes[i]; const b2 = bytes[i + 1]; const b3 = bytes[i + 2]; output += chars.charAt(b1 >> 2); output += chars.charAt(((b1 & 3) << 4) | (b2 >> 4) || 0); output += b2 !== undefined ? chars.charAt(((b2 & 15) << 2) | (b3 >> 6) || 0) : '='; output += b3 !== undefined ? chars.charAt(b3 & 63) : '='; } return output; } _base64ToUint8Array(base64) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const lookup = new Uint8Array(256); for (let i = 0; i < chars.length; i++) { lookup[chars.charCodeAt(i)] = i; } let bufferLength = base64.length * 0.75; if (base64[base64.length - 1] === '=') bufferLength--; if (base64[base64.length - 2] === '=') bufferLength--; const bytes = new Uint8Array(bufferLength); let p = 0; for (let i = 0; i < base64.length; i += 4) { const e1 = lookup[base64.charCodeAt(i)]; const e2 = lookup[base64.charCodeAt(i + 1)]; const e3 = lookup[base64.charCodeAt(i + 2)]; const e4 = lookup[base64.charCodeAt(i + 3)]; bytes[p++] = (e1 << 2) | (e2 >> 4); if (base64[i + 2] !== '=') bytes[p++] = ((e2 & 15) << 4) | (e3 >> 2); if (base64[i + 3] !== '=') bytes[p++] = ((e3 & 3) << 6) | e4; } return bytes; } _utf8Decode(bytes) { let out = ""; let i = 0; while (i < bytes.length) { let c = bytes[i++]; if (c >> 7 == 0) { out += String.fromCharCode(c); } else if (c >> 5 == 0x06) { out += String.fromCharCode(((c & 0x1F) << 6) | (bytes[i++] & 0x3F)); } else if (c >> 4 == 0x0E) { out += String.fromCharCode(((c & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F)); } else { out += String.fromCharCode(((c & 0x07) << 18) | ((bytes[i++] & 0x3F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F)); } } return out; } _parseUserIdFromToken(token) { try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = parts[1]; // Base64 URL 解码 const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4); // 解码 base64 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const lookup = {}; for (let i = 0; i < chars.length; i++) { lookup[chars[i]] = i; } let bytes = []; for (let i = 0; i < padded.length; i += 4) { const e1 = lookup[padded[i]] || 0; const e2 = lookup[padded[i + 1]] || 0; const e3 = lookup[padded[i + 2]] || 0; const e4 = lookup[padded[i + 3]] || 0; bytes.push((e1 << 2) | (e2 >> 4)); if (padded[i + 2] !== '=') bytes.push(((e2 & 15) << 4) | (e3 >> 2)); if (padded[i + 3] !== '=') bytes.push(((e3 & 3) << 6) | e4); } // UTF-8 解码 let str = ''; for (let i = 0; i < bytes.length; i++) { str += String.fromCharCode(bytes[i]); } const parsed = JSON.parse(str); return parsed.uid || parsed.sub || null; } catch (e) { console.error('[Nakama] Failed to parse token:', e); return null; } } } export const nakamaManager = new NakamaManager();