593 lines
15 KiB
JavaScript
593 lines
15 KiB
JavaScript
/**
|
|
* NetworkManager.js
|
|
* Manages WebSocket connection for PVP online multiplayer.
|
|
* Handles connection lifecycle, heartbeat, reconnection, and message routing.
|
|
*/
|
|
|
|
const { NET_MSG } = require('../base/GameGlobal');
|
|
|
|
class NetworkManager {
|
|
constructor() {
|
|
/** @type {WebSocket|null} */
|
|
this._ws = null;
|
|
/** @type {string} Server URL */
|
|
this._serverUrl = '';
|
|
/** @type {boolean} */
|
|
this._connected = false;
|
|
/** @type {boolean} */
|
|
this._connecting = false;
|
|
/** @type {string|null} Current room ID */
|
|
this._roomId = null;
|
|
/** @type {number} Player slot (1 or 2) */
|
|
this._playerSlot = 0;
|
|
/** @type {string} Unique player ID */
|
|
this._playerId = '';
|
|
|
|
// Heartbeat
|
|
this._heartbeatInterval = null;
|
|
this._heartbeatTimeout = null;
|
|
this._heartbeatMs = 5000;
|
|
this._heartbeatTimeoutMs = 10000;
|
|
|
|
// Reconnection
|
|
this._reconnectAttempts = 0;
|
|
this._maxReconnectAttempts = 3;
|
|
this._reconnectDelay = 2000;
|
|
this._reconnectTimer = null;
|
|
this._shouldReconnect = false;
|
|
|
|
// Message handlers
|
|
/** @type {Map<string, Array<Function>>} */
|
|
this._handlers = new Map();
|
|
|
|
// Latency tracking
|
|
this._lastPingTime = 0;
|
|
this._latency = 0;
|
|
|
|
// Generate a unique player ID
|
|
this._playerId = this._generatePlayerId();
|
|
}
|
|
|
|
/**
|
|
* Connect to the WebSocket server.
|
|
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
|
|
* @param {number} [timeoutMs=10000] - Connect timeout in milliseconds.
|
|
* @returns {Promise<boolean>} Whether connection succeeded.
|
|
*/
|
|
connect(serverUrl, timeoutMs = 10000) {
|
|
return new Promise((resolve) => {
|
|
if (this._connected || this._connecting) {
|
|
resolve(this._connected);
|
|
return;
|
|
}
|
|
|
|
this._serverUrl = serverUrl;
|
|
this._connecting = true;
|
|
this._shouldReconnect = true;
|
|
|
|
// Guard: make sure resolve is called exactly once.
|
|
let settled = false;
|
|
const finish = (ok, reason) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (connectTimer) {
|
|
clearTimeout(connectTimer);
|
|
connectTimer = null;
|
|
}
|
|
if (!ok) {
|
|
// Tear down broken socket so next connect() starts clean.
|
|
this._connecting = false;
|
|
this._shouldReconnect = false; // a first-time failure should NOT auto-reconnect
|
|
if (this._ws) {
|
|
try { this._ws.close({}); } catch (e) { /* ignore */ }
|
|
this._ws = null;
|
|
}
|
|
console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
|
|
}
|
|
resolve(ok);
|
|
};
|
|
|
|
// Connection timeout guard (e.g. DNS/TLS hang on cellular).
|
|
let connectTimer = setTimeout(() => {
|
|
finish(false, `connect timeout after ${timeoutMs}ms, url=${serverUrl}`);
|
|
}, timeoutMs);
|
|
|
|
try {
|
|
this._ws = wx.connectSocket({
|
|
url: serverUrl,
|
|
header: { 'content-type': 'application/json' },
|
|
// Surface wx.connectSocket API-level failures (invalid url / domain not whitelisted / etc.)
|
|
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
|
|
fail: (err) => {
|
|
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
|
|
finish(false, `wx.connectSocket fail: ${err && err.errMsg}`);
|
|
},
|
|
});
|
|
|
|
this._ws.onOpen(() => {
|
|
console.log('[NetworkManager] Connected to server:', serverUrl);
|
|
this._connected = true;
|
|
this._connecting = false;
|
|
this._reconnectAttempts = 0;
|
|
this._startHeartbeat();
|
|
this._emit('connected');
|
|
finish(true);
|
|
});
|
|
|
|
this._ws.onMessage((res) => {
|
|
this._handleMessage(res.data);
|
|
});
|
|
|
|
this._ws.onError((err) => {
|
|
// Log as much context as possible; wx error objects vary across platforms.
|
|
console.error('[NetworkManager] WebSocket error:',
|
|
(err && (err.errMsg || err.message)) || err,
|
|
'url=', serverUrl);
|
|
this._emit('error', err);
|
|
// If the error arrives before we ever got onOpen, treat it as a connect failure.
|
|
if (!this._connected) {
|
|
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
|
|
} else {
|
|
// Runtime error on an established connection — let onClose handle reconnection.
|
|
this._connecting = false;
|
|
}
|
|
});
|
|
|
|
this._ws.onClose((res) => {
|
|
const code = res && res.code;
|
|
const reason = res && res.reason;
|
|
console.log('[NetworkManager] Connection closed:', code, reason, 'url=', serverUrl);
|
|
|
|
const wasConnected = this._connected;
|
|
this._connected = false;
|
|
this._connecting = false;
|
|
this._stopHeartbeat();
|
|
this._emit('disconnected', { code, reason });
|
|
|
|
// If onClose arrives before onOpen, this is a connect failure.
|
|
if (!wasConnected) {
|
|
finish(false, `onClose before open: code=${code} reason=${reason}`);
|
|
return;
|
|
}
|
|
|
|
// Auto-reconnect only for drops on an already-established connection.
|
|
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
|
|
this._attemptReconnect();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('[NetworkManager] Failed to create WebSocket:', e);
|
|
finish(false, `exception: ${e && e.message}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the server.
|
|
*/
|
|
disconnect() {
|
|
this._shouldReconnect = false;
|
|
this._stopHeartbeat();
|
|
this._clearReconnectTimer();
|
|
|
|
if (this._ws) {
|
|
try {
|
|
this._ws.close({});
|
|
} catch (e) {
|
|
// Ignore close errors
|
|
}
|
|
this._ws = null;
|
|
}
|
|
|
|
this._connected = false;
|
|
this._connecting = false;
|
|
this._roomId = null;
|
|
this._playerSlot = 0;
|
|
}
|
|
|
|
/**
|
|
* Send a message to the server.
|
|
* @param {string} type - Message type from NET_MSG.
|
|
* @param {object} [data={}] - Message payload.
|
|
*/
|
|
send(type, data = {}) {
|
|
if (!this._connected || !this._ws) {
|
|
console.warn('[NetworkManager] Cannot send, not connected');
|
|
return;
|
|
}
|
|
|
|
// Always include the player's current nickname (if any) so the server
|
|
// can propagate it to other clients. Falls back silently when the
|
|
// profile is not yet available.
|
|
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
|
const nickname = (profile && profile.nickname) ? profile.nickname : '';
|
|
|
|
const message = JSON.stringify({
|
|
type,
|
|
data,
|
|
playerId: this._playerId,
|
|
nickname,
|
|
roomId: this._roomId,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
try {
|
|
this._ws.send({ data: message });
|
|
} catch (e) {
|
|
console.error('[NetworkManager] Send error:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new room on the server.
|
|
*/
|
|
createRoom() {
|
|
this.send(NET_MSG.CREATE_ROOM, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Join an existing room.
|
|
* @param {string} roomId - Room ID to join.
|
|
*/
|
|
joinRoom(roomId) {
|
|
this.send(NET_MSG.JOIN_ROOM, {
|
|
playerId: this._playerId,
|
|
roomId: roomId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send player input to the server.
|
|
* @param {object} input - { direction, firing, x, y }
|
|
*/
|
|
sendInput(input) {
|
|
this.send(NET_MSG.PLAYER_INPUT, input);
|
|
}
|
|
|
|
/**
|
|
* Send player state for synchronization.
|
|
* @param {object} state - { x, y, direction, hp, alive }
|
|
*/
|
|
sendState(state) {
|
|
this.send(NET_MSG.PLAYER_STATE, state);
|
|
}
|
|
|
|
/**
|
|
* Send bullet fire event.
|
|
* @param {object} bulletData - { x, y, direction }
|
|
*/
|
|
sendBulletFire(bulletData) {
|
|
this.send(NET_MSG.BULLET_FIRE, bulletData);
|
|
}
|
|
|
|
// ============================================================
|
|
// 3v3 Team Methods
|
|
// ============================================================
|
|
|
|
/**
|
|
* Create a new team for 3v3 mode.
|
|
*/
|
|
createTeam() {
|
|
this.send(NET_MSG.CREATE_TEAM, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Join an existing team by teamId.
|
|
* @param {string} teamId - Team ID to join.
|
|
*/
|
|
joinTeam(teamId) {
|
|
this.send(NET_MSG.JOIN_TEAM, {
|
|
playerId: this._playerId,
|
|
teamId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Leave the current team.
|
|
*/
|
|
leaveTeam() {
|
|
this.send(NET_MSG.LEAVE_TEAM, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Toggle ready state in team room.
|
|
* @param {boolean} ready - Whether the player is ready.
|
|
*/
|
|
teamReady(ready) {
|
|
this.send(NET_MSG.TEAM_READY, {
|
|
playerId: this._playerId,
|
|
ready,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start matchmaking (leader only).
|
|
*/
|
|
startMatch() {
|
|
this.send(NET_MSG.MATCH_START, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancel matchmaking (leader only).
|
|
*/
|
|
cancelMatch() {
|
|
this.send(NET_MSG.MATCH_CANCEL, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Kick a player from the team (leader only).
|
|
* @param {string} targetPlayerId - Player ID to kick.
|
|
*/
|
|
kickPlayer(targetPlayerId) {
|
|
this.send(NET_MSG.TEAM_KICK, {
|
|
playerId: this._playerId,
|
|
targetPlayerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Disband the team (leader only).
|
|
*/
|
|
disbandTeam() {
|
|
this.send(NET_MSG.TEAM_DISBAND, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start solo matchmaking for 3v3.
|
|
*/
|
|
soloMatch() {
|
|
this.send(NET_MSG.SOLO_MATCH, {
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt to reconnect to an ongoing team game.
|
|
* @param {string} teamId - Team room ID.
|
|
*/
|
|
reconnectToTeam(teamId) {
|
|
this.send(NET_MSG.RECONNECT, {
|
|
teamId,
|
|
playerId: this._playerId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register a handler for a message type.
|
|
* @param {string} type - Message type.
|
|
* @param {Function} handler - Callback function(data).
|
|
* @returns {Function} Unsubscribe function.
|
|
*/
|
|
on(type, handler) {
|
|
if (!this._handlers.has(type)) {
|
|
this._handlers.set(type, []);
|
|
}
|
|
this._handlers.get(type).push(handler);
|
|
|
|
return () => {
|
|
const list = this._handlers.get(type);
|
|
if (list) {
|
|
const idx = list.indexOf(handler);
|
|
if (idx !== -1) list.splice(idx, 1);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove a handler for a message type.
|
|
* @param {string} type
|
|
* @param {Function} handler
|
|
*/
|
|
off(type, handler) {
|
|
const list = this._handlers.get(type);
|
|
if (list) {
|
|
const idx = list.indexOf(handler);
|
|
if (idx !== -1) list.splice(idx, 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove all handlers.
|
|
*/
|
|
clearHandlers() {
|
|
this._handlers.clear();
|
|
}
|
|
|
|
// ============================================================
|
|
// Private Methods
|
|
// ============================================================
|
|
|
|
/**
|
|
* Handle incoming WebSocket message.
|
|
* @private
|
|
*/
|
|
_handleMessage(rawData) {
|
|
try {
|
|
const msg = JSON.parse(rawData);
|
|
const { type, data } = msg;
|
|
|
|
// Handle system messages
|
|
if (type === NET_MSG.PONG) {
|
|
this._latency = Date.now() - this._lastPingTime;
|
|
this._resetHeartbeatTimeout();
|
|
return;
|
|
}
|
|
|
|
if (type === NET_MSG.ROOM_CREATED) {
|
|
this._roomId = data.roomId;
|
|
this._playerSlot = 1; // Creator is player 1
|
|
} else if (type === NET_MSG.ROOM_JOINED) {
|
|
this._roomId = data.roomId;
|
|
this._playerSlot = data.playerSlot || 2;
|
|
}
|
|
|
|
// Emit to registered handlers
|
|
this._emit(type, data);
|
|
} catch (e) {
|
|
console.error('[NetworkManager] Failed to parse message:', e, rawData);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit an event to registered handlers.
|
|
* @private
|
|
*/
|
|
_emit(type, data) {
|
|
const list = this._handlers.get(type);
|
|
if (list) {
|
|
for (const handler of list) {
|
|
try {
|
|
handler(data);
|
|
} catch (e) {
|
|
console.error(`[NetworkManager] Handler error for "${type}":`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start heartbeat ping/pong.
|
|
* @private
|
|
*/
|
|
_startHeartbeat() {
|
|
this._stopHeartbeat();
|
|
this._heartbeatInterval = setInterval(() => {
|
|
if (this._connected) {
|
|
this._lastPingTime = Date.now();
|
|
this.send(NET_MSG.PING);
|
|
this._startHeartbeatTimeout();
|
|
}
|
|
}, this._heartbeatMs);
|
|
}
|
|
|
|
/**
|
|
* Stop heartbeat.
|
|
* @private
|
|
*/
|
|
_stopHeartbeat() {
|
|
if (this._heartbeatInterval) {
|
|
clearInterval(this._heartbeatInterval);
|
|
this._heartbeatInterval = null;
|
|
}
|
|
this._resetHeartbeatTimeout();
|
|
}
|
|
|
|
/**
|
|
* Start heartbeat timeout (disconnect if no pong received).
|
|
* @private
|
|
*/
|
|
_startHeartbeatTimeout() {
|
|
this._resetHeartbeatTimeout();
|
|
this._heartbeatTimeout = setTimeout(() => {
|
|
console.warn('[NetworkManager] Heartbeat timeout, disconnecting');
|
|
this.disconnect();
|
|
}, this._heartbeatTimeoutMs);
|
|
}
|
|
|
|
/**
|
|
* Reset heartbeat timeout.
|
|
* @private
|
|
*/
|
|
_resetHeartbeatTimeout() {
|
|
if (this._heartbeatTimeout) {
|
|
clearTimeout(this._heartbeatTimeout);
|
|
this._heartbeatTimeout = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempt to reconnect to the server.
|
|
* @private
|
|
*/
|
|
_attemptReconnect() {
|
|
this._clearReconnectTimer();
|
|
this._reconnectAttempts++;
|
|
console.log(`[NetworkManager] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`);
|
|
|
|
this._emit('reconnecting', { attempt: this._reconnectAttempts });
|
|
|
|
this._reconnectTimer = setTimeout(() => {
|
|
this.connect(this._serverUrl);
|
|
}, this._reconnectDelay * this._reconnectAttempts);
|
|
}
|
|
|
|
/**
|
|
* Clear reconnect timer.
|
|
* @private
|
|
*/
|
|
_clearReconnectTimer() {
|
|
if (this._reconnectTimer) {
|
|
clearTimeout(this._reconnectTimer);
|
|
this._reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique player ID.
|
|
* @private
|
|
* @returns {string}
|
|
*/
|
|
_generatePlayerId() {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let id = 'p_';
|
|
for (let i = 0; i < 8; i++) {
|
|
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return id + '_' + Date.now().toString(36);
|
|
}
|
|
|
|
// ============================================================
|
|
// Getters
|
|
// ============================================================
|
|
|
|
/** Whether currently connected. */
|
|
get connected() {
|
|
return this._connected;
|
|
}
|
|
|
|
/** Current room ID. */
|
|
get roomId() {
|
|
return this._roomId;
|
|
}
|
|
|
|
/** Player slot (1 or 2). */
|
|
get playerSlot() {
|
|
return this._playerSlot;
|
|
}
|
|
|
|
/** Player unique ID. */
|
|
get playerId() {
|
|
return this._playerId;
|
|
}
|
|
|
|
/** Player display nickname (may be empty until profile is fetched). */
|
|
get nickname() {
|
|
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
|
|
return (profile && profile.nickname) ? profile.nickname : '';
|
|
}
|
|
|
|
/** Current latency in ms. */
|
|
get latency() {
|
|
return this._latency;
|
|
}
|
|
|
|
/** Whether currently connecting. */
|
|
get connecting() {
|
|
return this._connecting;
|
|
}
|
|
}
|
|
|
|
module.exports = NetworkManager;
|