first commit
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* 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').
|
||||
* @returns {Promise<boolean>} Whether connection succeeded.
|
||||
*/
|
||||
connect(serverUrl) {
|
||||
return new Promise((resolve) => {
|
||||
if (this._connected || this._connecting) {
|
||||
resolve(this._connected);
|
||||
return;
|
||||
}
|
||||
|
||||
this._serverUrl = serverUrl;
|
||||
this._connecting = true;
|
||||
this._shouldReconnect = true;
|
||||
|
||||
try {
|
||||
this._ws = wx.connectSocket({
|
||||
url: serverUrl,
|
||||
header: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
this._ws.onOpen(() => {
|
||||
console.log('[NetworkManager] Connected to server');
|
||||
this._connected = true;
|
||||
this._connecting = false;
|
||||
this._reconnectAttempts = 0;
|
||||
this._startHeartbeat();
|
||||
this._emit('connected');
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
this._ws.onMessage((res) => {
|
||||
this._handleMessage(res.data);
|
||||
});
|
||||
|
||||
this._ws.onError((err) => {
|
||||
console.error('[NetworkManager] WebSocket error:', err);
|
||||
this._connecting = false;
|
||||
this._emit('error', err);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
this._ws.onClose((res) => {
|
||||
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
|
||||
this._connected = false;
|
||||
this._connecting = false;
|
||||
this._stopHeartbeat();
|
||||
this._emit('disconnected', { code: res.code, reason: res.reason });
|
||||
|
||||
// Auto-reconnect if needed
|
||||
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
|
||||
this._attemptReconnect();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[NetworkManager] Failed to create WebSocket:', e);
|
||||
this._connecting = false;
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const message = JSON.stringify({
|
||||
type,
|
||||
data,
|
||||
playerId: this._playerId,
|
||||
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;
|
||||
}
|
||||
|
||||
/** Current latency in ms. */
|
||||
get latency() {
|
||||
return this._latency;
|
||||
}
|
||||
|
||||
/** Whether currently connecting. */
|
||||
get connecting() {
|
||||
return this._connecting;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NetworkManager;
|
||||
Reference in New Issue
Block a user