569 lines
19 KiB
JavaScript
569 lines
19 KiB
JavaScript
/**
|
||
* 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();
|