bindbox-mini/utils/nakamaManager.js

569 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
this.isConnecting = 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秒
this.heartbeatId = 0; // 用于识别心跳版本的 ID
}
/**
* 初始化客户端配置
*/
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, externalUserId = null) {
this.gameToken = gameToken;
let customId = externalUserId;
if (!customId) {
// 获取或生成持久化的 custom ID
customId = uni.getStorageSync('nakama_custom_id');
if (!customId) {
customId = `game_${Date.now()}_${Math.random().toString(36).substring(7)}`;
uni.setStorageSync('nakama_custom_id', customId);
}
}
console.log('[Nakama] Authenticating with ID:', customId, externalUserId ? '(Account-based)' : '(Device-based)');
// 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() {
if (this.isConnecting) {
console.log('[Nakama] Already connecting, skipping...');
return Promise.resolve();
}
return new Promise((resolve, reject) => {
// 确保清理旧连接
if (this.socketTask) {
console.log('[Nakama] Closing existing socket before new connection');
try {
this.socketTask.close();
} catch (e) {
console.warn('[Nakama] Error closing old socket:', e);
}
this.socketTask = null;
}
this.isConnecting = true;
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;
this.isConnecting = false;
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;
this.isConnecting = 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)
}
});
console.log('[Nakama] RPC response:', id, response);
if (response.rpc && response.rpc.payload) {
try {
const parsed = JSON.parse(response.rpc.payload);
console.log('[Nakama] RPC parsed result:', id, parsed);
return parsed;
} catch (e) {
return response.rpc.payload;
}
}
return response;
}
/**
* 发送游戏状态
*/
sendMatchState(matchId, opCode, data) {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
const op = parseInt(opCode);
console.log(`[Nakama] Sending state: Match=${matchId}, OpCode=${op}`);
this._sendNoResponse({
match_data_send: {
match_id: matchId,
op_code: op,
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();
const currentHeartbeatId = ++this.heartbeatId;
console.log('[Nakama] Starting heartbeat version:', currentHeartbeatId);
this.heartbeatTimer = setInterval(() => {
// 如果此心跳 ID 不再是当前活跃 ID立即停止
if (this.heartbeatId !== currentHeartbeatId) {
console.log('[Nakama] Zombie heartbeat detected and stopped:', currentHeartbeatId);
clearInterval(this.heartbeatTimer);
return;
}
if (this.isConnected) {
this._send({ ping: {} }, 5000).catch((err) => {
console.warn('[Nakama] Heartbeat failed:', err.message);
// 如果发送失败且 socketTask 已断开,触发清理
if (!this.socketTask || (err.message && err.message.includes('not connected'))) {
this.disconnect();
}
});
}
}, 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();