125 lines
3.0 KiB
TypeScript
125 lines
3.0 KiB
TypeScript
/**
|
|
* WebSocket Client
|
|
* 纯粹的 Socket 封装,不依赖 Store
|
|
*/
|
|
|
|
export type MessageHandler = (data: any) => void;
|
|
|
|
export interface SocketOptions {
|
|
url?: string;
|
|
reconnectInterval?: number;
|
|
maxReconnectAttempts?: number;
|
|
}
|
|
|
|
export class GameSocket {
|
|
private ws: WebSocket | null = null;
|
|
private handlers: Set<MessageHandler> = new Set();
|
|
private statusHandlers: Set<(connected: boolean) => void> = new Set();
|
|
|
|
private reconnectTimer: number | null = null;
|
|
private attempts = 0;
|
|
private isIntentionalClose = false;
|
|
private options: SocketOptions;
|
|
|
|
constructor(options: SocketOptions = {}) {
|
|
this.options = options;
|
|
}
|
|
|
|
public connect() {
|
|
this.isIntentionalClose = false;
|
|
this.cleanup();
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const host = window.location.host;
|
|
const url = this.options.url || `${protocol}//${host}/ws`;
|
|
|
|
try {
|
|
this.ws = new WebSocket(url);
|
|
this.ws.onopen = this.onOpen.bind(this);
|
|
this.ws.onmessage = this.onMessage.bind(this);
|
|
this.ws.onclose = this.onClose.bind(this);
|
|
this.ws.onerror = this.onError.bind(this);
|
|
} catch (e) {
|
|
console.error('WS Connection failed', e);
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
public disconnect() {
|
|
this.isIntentionalClose = true;
|
|
this.cleanup();
|
|
this.notifyStatus(false);
|
|
}
|
|
|
|
public on(handler: MessageHandler) {
|
|
this.handlers.add(handler);
|
|
return () => this.handlers.delete(handler);
|
|
}
|
|
|
|
public onStatusChange(handler: (connected: boolean) => void) {
|
|
this.statusHandlers.add(handler);
|
|
return () => this.statusHandlers.delete(handler);
|
|
}
|
|
|
|
private onOpen() {
|
|
this.attempts = 0;
|
|
this.notifyStatus(true);
|
|
}
|
|
|
|
private onMessage(event: MessageEvent) {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handlers.forEach(h => h(data));
|
|
} catch (e) {
|
|
console.warn('Failed to parse WS message', e);
|
|
}
|
|
}
|
|
|
|
private onClose() {
|
|
this.notifyStatus(false);
|
|
if (!this.isIntentionalClose) {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
private onError() {
|
|
// Error usually precedes Close, so we handle logic in Close
|
|
}
|
|
|
|
private cleanup() {
|
|
if (this.reconnectTimer) {
|
|
window.clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
if (this.ws) {
|
|
this.ws.onopen = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.onclose = null;
|
|
this.ws.onerror = null;
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect() {
|
|
const max = this.options.maxReconnectAttempts ?? 10;
|
|
if (this.attempts >= max) return;
|
|
|
|
const base = this.options.reconnectInterval ?? 1000;
|
|
const delay = Math.min(10000, base * (2 ** this.attempts));
|
|
|
|
this.reconnectTimer = window.setTimeout(() => {
|
|
this.attempts++;
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private notifyStatus(connected: boolean) {
|
|
this.statusHandlers.forEach(h => h(connected));
|
|
}
|
|
}
|
|
|
|
// 单例实例
|
|
export const gameSocket = new GameSocket();
|
|
|