EventBus + WebSockets
Полное руководство по созданию архитектуры реального времени для многопользовательских игр
Введение
Что такое EventBus и WebSockets
При разработке многопользовательских игр и приложений реального времени ключевую роль играют два паттерна: EventBus (шина событий) и WebSockets. Вместе они образуют мощную архитектуру для обмена сообщениями между клиентами и сервером.
🔌 WebSocket
WebSocket — протокол для двустороннего обмена данными между клиентом и сервером в режиме реального времени. В отличие от HTTP, соединение остается открытым, что позволяет мгновенно отправлять и получать сообщения.
📡 EventBus
EventBus (шина событий) — паттерн проектирования, который позволяет компонентам системы общаться друг с другом через события, не зная напрямую друг о друге. Это обеспечивает слабую связанность (loose coupling) между модулями.
WebSocket отвечает за транспорт данных между клиентом и сервером, а EventBus — за маршрутизацию событий внутри приложения. Это разделение позволяет легко менять транспорт (WebSocket → HTTP long-polling → SSE) без изменения бизнес-логики.
Реализация EventBus
Полнофункциональная шина событий
🎯 Основные возможности
- Многоразовая подписка (on) — обработчик вызывается при каждом событии
- Одноразовая подписка (once) — обработчик вызывается только один раз
- Отписка (off) — удаление обработчика
- Приоритеты — порядок вызова обработчиков
- Фильтрация событий — условная обработка
- Wildcard подписки — подписка на группы событий
- Типизация — полная поддержка TypeScript
/**
* Типы для EventBus
*/
type EventCallback<T = any> = (data: T) => void | Promise<void>;
interface Subscription {
id: string;
callback: EventCallback;
once: boolean;
priority: number;
filter?: (data: any) => boolean;
}
interface SubscriptionOptions {
once?: boolean;
priority?: number;
filter?: (data: any) => boolean;
}
/**
* EventBus - Шина событий с поддержкой TypeScript
*
* Возможности:
* - Многоразовые и одноразовые подписки
* - Приоритеты обработчиков
* - Фильтрация событий
* - Wildcard подписки (player.*, *.killed)
* - Полная типизация
*/
class EventBus<Events extends Record<string, any> = Record<string, any>> {
private subscriptions: Map<string, Subscription[]> = new Map();
private subscriptionCounter: number = 0;
private eventHistory: Array<{ event: string; data: any; timestamp: number }> = [];
private maxHistorySize: number = 100;
/**
* Генерация уникального ID подписки
*/
private generateId(): string {
return `sub_${++this.subscriptionCounter}_${Date.now()}`;
}
/**
* Подписка на событие (многоразовая)
* @param event - Имя события или wildcard паттерн
* @param callback - Функция-обработчик
* @param options - Опции подписки
* @returns Функция для отписки
*/
on<K extends keyof Events>(
event: K | string,
callback: EventCallback<Events[K]>,
options: SubscriptionOptions = {}
): () => void {
const eventName = String(event);
const subscription: Subscription = {
id: this.generateId(),
callback,
once: options.once ?? false,
priority: options.priority ?? 0,
filter: options.filter
};
if (!this.subscriptions.has(eventName)) {
this.subscriptions.set(eventName, []);
}
const subs = this.subscriptions.get(eventName)!;
subs.push(subscription);
// Сортировка по приоритету (высший приоритет первым)
subs.sort((a, b) => b.priority - a.priority);
// Возвращаем функцию отписки
return () => this.off(eventName, subscription.id);
}
/**
* Одноразовая подписка на событие
* Обработчик автоматически удаляется после первого вызова
*/
once<K extends keyof Events>(
event: K | string,
callback: EventCallback<Events[K]>,
options: Omit<SubscriptionOptions, 'once'> = {}
): () => void {
return this.on(event, callback, { ...options, once: true });
}
/**
* Отписка от события
* @param event - Имя события
* @param subscriptionId - ID подписки или функция-обработчик
*/
off(event: string, subscriptionId?: string | EventCallback): void {
const subs = this.subscriptions.get(event);
if (!subs) return;
if (!subscriptionId) {
// Удалить все подписки на событие
this.subscriptions.delete(event);
return;
}
const index = subs.findIndex(s =>
typeof subscriptionId === 'string'
? s.id === subscriptionId
: s.callback === subscriptionId
);
if (index !== -1) {
subs.splice(index, 1);
}
}
/**
* Публикация события
* @param event - Имя события
* @param data - Данные события
*/
async emit<K extends keyof Events>(
event: K | string,
data?: Events[K]
): Promise<void> {
const eventName = String(event);
// Сохраняем в историю
this.addToHistory(eventName, data);
// Собираем все подходящие подписки
const matchingSubscriptions: Subscription[] = [];
// Прямые подписки
const directSubs = this.subscriptions.get(eventName) || [];
matchingSubscriptions.push(...directSubs);
// Wildcard подписки
this.subscriptions.forEach((subs, pattern) => {
if (pattern.includes('*') && this.matchWildcard(pattern, eventName)) {
matchingSubscriptions.push(...subs);
}
});
// Сортируем по приоритету
matchingSubscriptions.sort((a, b) => b.priority - a.priority);
// Подписки для удаления (once)
const toRemove: Subscription[] = [];
// Вызываем обработчики
for (const sub of matchingSubscriptions) {
// Проверяем фильтр
if (sub.filter && !sub.filter(data)) {
continue;
}
try {
await sub.callback(data);
} catch (error) {
console.error(`Error in event handler for "${eventName}":`, error);
}
if (sub.once) {
toRemove.push(sub);
}
}
// Удаляем одноразовые подписки
toRemove.forEach(sub => {
this.subscriptions.forEach((subs, event) => {
const index = subs.indexOf(sub);
if (index !== -1) subs.splice(index, 1);
});
});
}
/**
* Проверка wildcard паттерна
*/
private matchWildcard(pattern: string, event: string): boolean {
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '[^.]*');
return new RegExp(`^${regexPattern}$`).test(event);
}
/**
* Добавление события в историю
*/
private addToHistory(event: string, data: any): void {
this.eventHistory.push({
event,
data,
timestamp: Date.now()
});
if (this.eventHistory.length > this.maxHistorySize) {
this.eventHistory.shift();
}
}
/**
* Получение истории событий
*/
getHistory(eventFilter?: string): typeof this.eventHistory {
if (!eventFilter) return [...this.eventHistory];
return this.eventHistory.filter(h =>
this.matchWildcard(eventFilter, h.event)
);
}
/**
* Очистка всех подписок
*/
clear(): void {
this.subscriptions.clear();
}
/**
* Подсчет подписок
*/
listenerCount(event?: string): number {
if (event) {
return this.subscriptions.get(event)?.length ?? 0;
}
let count = 0;
this.subscriptions.forEach(subs => count += subs.length);
return count;
}
/**
* Список всех событий с подписками
*/
eventNames(): string[] {
return Array.from(this.subscriptions.keys());
}
}
export { EventBus, EventCallback, SubscriptionOptions };
/**
* EventBus - Шина событий
* JavaScript версия без типизации
*/
class EventBus {
constructor() {
this.subscriptions = new Map();
this.subscriptionCounter = 0;
this.eventHistory = [];
this.maxHistorySize = 100;
}
/**
* Генерация уникального ID
*/
#generateId() {
return `sub_${++this.subscriptionCounter}_${Date.now()}`;
}
/**
* Многоразовая подписка
* @param {string} event - Имя события
* @param {Function} callback - Обработчик
* @param {Object} options - Опции
* @returns {Function} - Функция отписки
*/
on(event, callback, options = {}) {
const subscription = {
id: this.#generateId(),
callback,
once: options.once ?? false,
priority: options.priority ?? 0,
filter: options.filter
};
if (!this.subscriptions.has(event)) {
this.subscriptions.set(event, []);
}
const subs = this.subscriptions.get(event);
subs.push(subscription);
subs.sort((a, b) => b.priority - a.priority);
return () => this.off(event, subscription.id);
}
/**
* Одноразовая подписка
*/
once(event, callback, options = {}) {
return this.on(event, callback, { ...options, once: true });
}
/**
* Отписка
*/
off(event, subscriptionId) {
const subs = this.subscriptions.get(event);
if (!subs) return;
if (!subscriptionId) {
this.subscriptions.delete(event);
return;
}
const index = subs.findIndex(s =>
typeof subscriptionId === 'string'
? s.id === subscriptionId
: s.callback === subscriptionId
);
if (index !== -1) subs.splice(index, 1);
}
/**
* Публикация события
*/
async emit(event, data) {
this.#addToHistory(event, data);
const matchingSubscriptions = [];
// Прямые подписки
const directSubs = this.subscriptions.get(event) || [];
matchingSubscriptions.push(...directSubs);
// Wildcard подписки
this.subscriptions.forEach((subs, pattern) => {
if (pattern.includes('*') && this.#matchWildcard(pattern, event)) {
matchingSubscriptions.push(...subs);
}
});
matchingSubscriptions.sort((a, b) => b.priority - a.priority);
const toRemove = [];
for (const sub of matchingSubscriptions) {
if (sub.filter && !sub.filter(data)) continue;
try {
await sub.callback(data);
} catch (error) {
console.error(`Error in handler for "${event}":`, error);
}if (sub.once) toRemove.push(sub);
}
// Удаляем одноразовые подписки
toRemove.forEach(sub => {
this.subscriptions.forEach(subs => {
const index = subs.indexOf(sub);
if (index !== -1) subs.splice(index, 1);
});
});
}
#matchWildcard(pattern, event) {
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '[^.]*');
return new RegExp(`^${regexPattern}$`).test(event);
}
#addToHistory(event, data) {
this.eventHistory.push({ event, data, timestamp: Date.now() });
if (this.eventHistory.length > this.maxHistorySize) {
this.eventHistory.shift();
}
}
getHistory(eventFilter) {
if (!eventFilter) return [...this.eventHistory];
return this.eventHistory.filter(h =>
this.#matchWildcard(eventFilter, h.event)
);
}
clear() { this.subscriptions.clear(); }
listenerCount(event) {
if (event) return this.subscriptions.get(event)?.length ?? 0;
let count = 0;
this.subscriptions.forEach(subs => count += subs.length);
return count;
}
eventNames() {
return Array.from(this.subscriptions.keys());
}
}
export { EventBus };
📝 Примеры использования EventBus
// Создаем экземпляр EventBus с типизацией событий
interface GameEvents {
'player.move': { playerId: string; x: number; y: number };
'player.attack': { playerId: string; targetId: string; damage: number };
'player.death': { playerId: string; killerId: string };
'game.start': { mapId: string; players: string[] };
'game.end': { winnerId: string; score: number };
}
const eventBus = new EventBus<GameEvents>();
// ═══════════════════════════════════════════════════════════
// 1. МНОГОРАЗОВАЯ ПОДПИСКА (on)
// Обработчик вызывается при каждом событии
// ═══════════════════════════════════════════════════════════
const unsubscribeMove = eventBus.on('player.move', (data) => {
console.log(`Player ${data.playerId} moved to (${data.x}, ${data.y})`);
});
// Эмитим несколько раз - обработчик вызовется каждый раз
eventBus.emit('player.move', { playerId: 'p1', x: 10, y: 20 });
eventBus.emit('player.move', { playerId: 'p1', x: 15, y: 25 });
eventBus.emit('player.move', { playerId: 'p1', x: 20, y: 30 });
// Все три вызова будут обработаны!
// Отписка когда больше не нужно
unsubscribeMove();
// ═══════════════════════════════════════════════════════════
// 2. ОДНОРАЗОВАЯ ПОДПИСКА (once)
// Обработчик вызывается только один раз, затем автоудаляется
// ═══════════════════════════════════════════════════════════
eventBus.once('game.start', (data) => {
console.log(`🎮 Game started on map ${data.mapId}!`);
console.log(`Players: ${data.players.join(', ')}`);
});
eventBus.emit('game.start', { mapId: 'arena_1', players: ['p1', 'p2', 'p3'] });
// ✅ Обработчик вызван
eventBus.emit('game.start', { mapId: 'arena_2', players: ['p4', 'p5'] });
// ❌ Обработчик НЕ вызван - уже удален после первого раза
// ═══════════════════════════════════════════════════════════
// 3. ПОДПИСКА С ПРИОРИТЕТОМ
// Обработчики с высшим приоритетом вызываются первыми
// ═══════════════════════════════════════════════════════════
eventBus.on('player.death', (data) => {
console.log('[LOW] Updating UI...');
}, { priority: 1 });
eventBus.on('player.death', (data) => {
console.log('[HIGH] Saving to database...');
}, { priority: 100 });
eventBus.on('player.death', (data) => {
console.log('[MEDIUM] Sending notification...');
}, { priority: 50 });
eventBus.emit('player.death', { playerId: 'p1', killerId: 'p2' });
// Порядок вызова:
// 1. [HIGH] Saving to database...
// 2. [MEDIUM] Sending notification...
// 3. [LOW] Updating UI...
// ═══════════════════════════════════════════════════════════
// 4. ПОДПИСКА С ФИЛЬТРОМ
// Обработчик вызывается только если фильтр возвращает true
// ═══════════════════════════════════════════════════════════
// Обрабатываем атаки только с уроном > 50
eventBus.on('player.attack', (data) => {
console.log(`💥 CRITICAL HIT! Damage: ${data.damage}`);
}, {
filter: (data) => data.damage > 50
});
eventBus.emit('player.attack', { playerId: 'p1', targetId: 'p2', damage: 30 });
// ❌ Не обработано (damage <= 50)
eventBus.emit('player.attack', { playerId: 'p1', targetId: 'p2', damage: 75 });
// ✅ Обработано: "💥 CRITICAL HIT! Damage: 75"
// ═══════════════════════════════════════════════════════════
// 5. WILDCARD ПОДПИСКИ
// Подписка на группу событий с паттерном
// ═══════════════════════════════════════════════════════════
// Подписка на все события player.*
eventBus.on('player.*', (data) => {
console.log('[PLAYER EVENT]', data);
});
// Подписка на все события *.death
eventBus.on('*.death', (data) => {
console.log('[DEATH EVENT]', data);
});
eventBus.emit('player.move', { playerId: 'p1', x: 0, y: 0 });
// ✅ [PLAYER EVENT] сработает
eventBus.emit('player.death', { playerId: 'p1', killerId: 'p2' });
// ✅ [PLAYER EVENT] сработает
// ✅ [DEATH EVENT] сработает
WebSocket Сервер и Клиент
Реализация для многопользовательской игры
📁 Структура проекта
🌐 Протокол сообщений
/**
* Типы сообщений между клиентом и сервером
*/
// Базовое сообщение
interface BaseMessage {
type: string;
timestamp: number;
messageId: string;
}
// ═══════════════════════════════════════════════════════════
// СООБЩЕНИЯ ОТ КЛИЕНТА К СЕРВЕРУ
// ═══════════════════════════════════════════════════════════
interface ClientJoinMessage extends BaseMessage {
type: 'client:join';
payload: {
playerName: string;
roomId?: string;
};
}
interface ClientMoveMessage extends BaseMessage {
type: 'client:move';
payload: {
x: number;
y: number;
direction: 'up' | 'down' | 'left' | 'right';
};
}
interface ClientAttackMessage extends BaseMessage {
type: 'client:attack';
payload: {
targetId: string;
skillId?: string;
};
}
interface ClientChatMessage extends BaseMessage {
type: 'client:chat';
payload: {
message: string;
channel: 'global' | 'room' | 'whisper';
targetId?: string;
};
}
// ═══════════════════════════════════════════════════════════
// СООБЩЕНИЯ ОТ СЕРВЕРА К КЛИЕНТУ
// ═══════════════════════════════════════════════════════════
interface ServerWelcomeMessage extends BaseMessage {
type: 'server:welcome';
payload: {
playerId: string;
roomId: string;
players: PlayerState[];
gameState: GameState;
};
}
interface ServerPlayerJoinedMessage extends BaseMessage {
type: 'server:player_joined';
payload: {
player: PlayerState;
};
}
interface ServerPlayerLeftMessage extends BaseMessage {
type: 'server:player_left';
payload: {
playerId: string;
reason: 'disconnect' | 'kick' | 'timeout';
};
}
interface ServerGameStateMessage extends BaseMessage {
type: 'server:game_state';
payload: {
players: PlayerState[];
entities: Entity[];
tick: number;
};
}
interface ServerDamageMessage extends BaseMessage {
type: 'server:damage';
payload: {
attackerId: string;
targetId: string;
damage: number;
isCritical: boolean;
targetHp: number;
};
}
interface ServerDeathMessage extends BaseMessage {
type: 'server:death';
payload: {
playerId: string;
killerId: string;
respawnTime: number;
};
}
interface ServerChatMessage extends BaseMessage {
type: 'server:chat';
payload: {
senderId: string;
senderName: string;
message: string;
channel: string;
};
}
interface ServerErrorMessage extends BaseMessage {
type: 'server:error';
payload: {
code: string;
message: string;
};
}
// Типы данных
interface PlayerState {
id: string;
name: string;
x: number;
y: number;
hp: number;
maxHp: number;
level: number;
isAlive: boolean;
}
interface Entity {
id: string;
type: string;
x: number;
y: number;
}
interface GameState {
status: 'waiting' | 'playing' | 'finished';
mapId: string;
tick: number;
}
// Объединенные типы
type ClientMessage =
| ClientJoinMessage
| ClientMoveMessage
| ClientAttackMessage
| ClientChatMessage;
type ServerMessage =
| ServerWelcomeMessage
| ServerPlayerJoinedMessage
| ServerPlayerLeftMessage
| ServerGameStateMessage
| ServerDamageMessage
| ServerDeathMessage
| ServerChatMessage
| ServerErrorMessage;
export {
ClientMessage, ServerMessage, PlayerState, Entity, GameState,
ClientJoinMessage, ClientMoveMessage, ClientAttackMessage, ClientChatMessage,
ServerWelcomeMessage, ServerPlayerJoinedMessage, ServerPlayerLeftMessage,
ServerGameStateMessage, ServerDamageMessage, ServerDeathMessage, ServerChatMessage
};
🖥️ WebSocket Сервер
import { WebSocketServer, WebSocket } from 'ws';
import { EventBus } from '../shared/EventBus';
import { ClientMessage, ServerMessage, PlayerState } from '../shared/Protocol';
/**
* Типизация внутренних событий сервера
*/
interface ServerEvents {
// Подключение/отключение
'connection.new': { clientId: string; socket: WebSocket };
'connection.close': { clientId: string; reason: string };
// Игровые события
'player.join': { clientId: string; playerName: string; roomId?: string };
'player.move': { playerId: string; x: number; y: number; direction: string };
'player.attack': { playerId: string; targetId: string; skillId?: string };
'player.death': { playerId: string; killerId: string };
// Чат
'chat.message': { playerId: string; message: string; channel: string };
// Комнаты
'room.created': { roomId: string };
'room.destroyed': { roomId: string };
}
/**
* Информация о подключенном клиенте
*/
interface Client {
id: string;
socket: WebSocket;
player?: PlayerState;
roomId?: string;
lastPing: number;
}
/**
* GameServer - WebSocket сервер для многопользовательской игры
*/
class GameServer {
private wss: WebSocketServer;
private eventBus: EventBus<ServerEvents>;
private clients: Map<string, Client> = new Map();
private rooms: Map<string, Set<string>> = new Map();
private tickRate: number = 20; // 20 тиков в секунду
private currentTick: number = 0;
constructor(port: number) {
this.eventBus = new EventBus<ServerEvents>();
this.wss = new WebSocketServer({ port });
this.setupWebSocket();
this.setupEventHandlers();
this.startGameLoop();
console.log(`🎮 Game Server started on port ${port}`);
}
/**
* Получить EventBus для внешних модулей
*/
getEventBus(): EventBus<ServerEvents> {
return this.eventBus;
}
/**
* Настройка WebSocket сервера
*/
private setupWebSocket(): void {
this.wss.on('connection', (socket: WebSocket) => {
const clientId = this.generateId();
// Создаем клиента
const client: Client = {
id: clientId,
socket,
lastPing: Date.now()
};
this.clients.set(clientId, client);
// Эмитим событие нового подключения
this.eventBus.emit('connection.new', { clientId, socket });
console.log(`✅ Client connected: ${clientId}`);
// Обработка входящих сообщений
socket.on('message', (data: Buffer) => {
try {
const message: ClientMessage = JSON.parse(data.toString());
this.handleClientMessage(clientId, message);
} catch (error) {
console.error('Invalid message:', error);
this.sendError(clientId, 'INVALID_MESSAGE', 'Invalid JSON message');
}
});
// Обработка отключения
socket.on('close', () => {
this.handleDisconnect(clientId);
});
// Обработка ошибок
socket.on('error', (error) => {
console.error(`Socket error for ${clientId}:`, error);
});
// Пинг для поддержания соединения
socket.on('pong', () => {
const client = this.clients.get(clientId);
if (client) client.lastPing = Date.now();
});
});
}
/**
* Обработка сообщений от клиента
*/
private handleClientMessage(clientId: string, message: ClientMessage): void {
const client = this.clients.get(clientId);
if (!client) return;
switch (message.type) {
case 'client:join':
this.eventBus.emit('player.join', {
clientId,
playerName: message.payload.playerName,
roomId: message.payload.roomId
});
break;
case 'client:move':
if (client.player) {
this.eventBus.emit('player.move', {
playerId: client.player.id,
...message.payload
});
}
break;
case 'client:attack':
if (client.player) {
this.eventBus.emit('player.attack', {
playerId: client.player.id,
...message.payload
});
}
break;
case 'client:chat':
if (client.player) {
this.eventBus.emit('chat.message', {
playerId: client.player.id,
message: message.payload.message,
channel: message.payload.channel
});
}
break;
}
}
/**
* Настройка обработчиков событий
*/
private setupEventHandlers(): void {
// Обработка входа игрока
this.eventBus.on('player.join', (data) => {
const client = this.clients.get(data.clientId);
if (!client) return;
// Создаем игрока
const player: PlayerState = {
id: data.clientId,
name: data.playerName,
x: Math.random() * 800,
y: Math.random() * 600,
hp: 100,
maxHp: 100,
level: 1,
isAlive: true
};
client.player = player;
// Добавляем в комнату
const roomId = data.roomId || 'default';
this.addToRoom(data.clientId, roomId);
// Отправляем приветствие
this.sendToClient(data.clientId, {
type: 'server:welcome',
timestamp: Date.now(),
messageId: this.generateId(),
payload: {
playerId: player.id,
roomId,
players: this.getPlayersInRoom(roomId),
gameState: {
status: 'playing',
mapId: 'default_map',
tick: this.currentTick
}
}
});
// Уведомляем других игроков в комнате
this.broadcastToRoom(roomId, {
type: 'server:player_joined',
timestamp: Date.now(),
messageId: this.generateId(),
payload: { player }
}, data.clientId);
console.log(`👤 Player joined: ${player.name} (${player.id})`);
});
// Обработка движения
this.eventBus.on('player.move', (data) => {
const client = this.getClientByPlayerId(data.playerId);
if (!client?.player) return;
// Обновляем позицию
client.player.x = data.x;
client.player.y = data.y;
});
// Обработка атаки
this.eventBus.on('player.attack', (data) => {
const attacker = this.getClientByPlayerId(data.playerId);
const target = this.getClientByPlayerId(data.targetId);
if (!attacker?.player || !target?.player) return;
if (!target.player.isAlive) return;
// Расчет урона
const damage = Math.floor(Math.random() * 20) + 10;
const isCritical = Math.random() < 0.15;
const finalDamage = isCritical ? damage * 2 : damage;
target.player.hp -= finalDamage;
// Отправляем информацию об уроне
if (attacker.roomId) {
this.broadcastToRoom(attacker.roomId, {
type: 'server:damage',
timestamp: Date.now(),
messageId: this.generateId(),
payload: {
attackerId: data.playerId,
targetId: data.targetId,
damage: finalDamage,
isCritical,
targetHp: target.player.hp
}
});
}
// Проверяем смерть
if (target.player.hp <= 0) {
target.player.hp = 0;
target.player.isAlive = false;
this.eventBus.emit('player.death', {
playerId: data.targetId,
killerId: data.playerId
});
}
});
// Обработка смерти игрока
this.eventBus.on('player.death', (data) => {
const client = this.getClientByPlayerId(data.playerId);
if (!client?.roomId) return;
this.broadcastToRoom(client.roomId, {
type: 'server:death',
timestamp: Date.now(),
messageId: this.generateId(),
payload: {
playerId: data.playerId,
killerId: data.killerId,
respawnTime: 5000
}
});
// Респаун через 5 секунд
setTimeout(() => {
if (client.player) {
client.player.hp = client.player.maxHp;
client.player.isAlive = true;
client.player.x = Math.random() * 800;
client.player.y = Math.random() * 600;
}
}, 5000);
console.log(`💀 Player ${data.playerId} killed by ${data.killerId}`);
});
}
/**
* Обработка отключения клиента
*/
private handleDisconnect(clientId: string): void {
const client = this.clients.get(clientId);
if (!client) return;
// Уведомляем комнату
if (client.roomId) {
this.broadcastToRoom(client.roomId, {
type: 'server:player_left',
timestamp: Date.now(),
messageId: this.generateId(),
payload: {
playerId: clientId,
reason: 'disconnect'
}
}, clientId);
this.removeFromRoom(clientId, client.roomId);
}
this.eventBus.emit('connection.close', { clientId, reason: 'disconnect' });
this.clients.delete(clientId);
console.log(`❌ Client disconnected: ${clientId}`);
}
/**
* Игровой цикл - отправка состояния игры
*/
private startGameLoop(): void {
setInterval(() => {
this.currentTick++;
// Отправляем состояние каждой комнате
this.rooms.forEach((clientIds, roomId) => {
const players = this.getPlayersInRoom(roomId);
this.broadcastToRoom(roomId, {
type: 'server:game_state',
timestamp: Date.now(),
messageId: this.generateId(),
payload: {
players,
entities: [],
tick: this.currentTick
}
});
});
}, 1000 / this.tickRate);
}
// ═══════════════════════════════════════════════════════════
// МЕТОДЫ ОТПРАВКИ СООБЩЕНИЙ
// ═══════════════════════════════════════════════════════════
private sendToClient(clientId: string, message: ServerMessage): void {
const client = this.clients.get(clientId);
if (client?.socket.readyState === WebSocket.OPEN) {
client.socket.send(JSON.stringify(message));
}
}
private broadcastToRoom(
roomId: string,
message: ServerMessage,
excludeClientId?: string
): void {
const room = this.rooms.get(roomId);
if (!room) return;
room.forEach(clientId => {
if (clientId !== excludeClientId) {
this.sendToClient(clientId, message);
}
});
}
private sendError(clientId: string, code: string, message: string): void {
this.sendToClient(clientId, {
type: 'server:error',
timestamp: Date.now(),
messageId: this.generateId(),
payload: { code, message }
});
}
// ═══════════════════════════════════════════════════════════
// ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ
// ═══════════════════════════════════════════════════════════
private generateId(): string {
return Math.random().toString(36).substring(2, 15);
}
private addToRoom(clientId: string, roomId: string): void {
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
this.rooms.get(roomId)!.add(clientId);
const client = this.clients.get(clientId);
if (client) client.roomId = roomId;
}
private removeFromRoom(clientId: string, roomId: string): void {
const room = this.rooms.get(roomId);
if (room) {
room.delete(clientId);
if (room.size === 0) {
this.rooms.delete(roomId);
}
}
}
private getPlayersInRoom(roomId: string): PlayerState[] {
const room = this.rooms.get(roomId);
if (!room) return [];
const players: PlayerState[] = [];
room.forEach(clientId => {
const client = this.clients.get(clientId);
if (client?.player) players.push(client.player);
});
return players;
}
private getClientByPlayerId(playerId: string): Client | undefined {
return this.clients.get(playerId);
}
}
export { GameServer };
💻 WebSocket Клиент
import { EventBus } from '../shared/EventBus';
import { ClientMessage, ServerMessage, PlayerState, GameState } from '../shared/Protocol';
/**
* События клиента
*/
interface ClientEvents {
// Соединение
'connection.open': void;
'connection.close': { code: number; reason: string };
'connection.error': { error: Error };
// Игровые события (от сервера)
'game.welcome': { playerId: string; roomId: string; players: PlayerState[]; gameState: GameState };
'game.state': { players: PlayerState[]; tick: number };
'game.playerJoined': { player: PlayerState };
'game.playerLeft': { playerId: string; reason: string };
// Боевые события
'combat.damage': { attackerId: string; targetId: string; damage: number; isCritical: boolean };
'combat.death': { playerId: string; killerId: string; respawnTime: number };
// Чат
'chat.message': { senderId: string; senderName: string; message: string; channel: string };
// Ошибки
'error': { code: string; message: string };
}
/**
* Состояние подключения
*/
enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting'
}
/**
* GameClient - WebSocket клиент для игры
*/
class GameClient {
private socket: WebSocket | null = null;
private eventBus: EventBus<ClientEvents>;
private serverUrl: string;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 5;
private reconnectDelay: number = 1000;
private messageQueue: ClientMessage[] = [];
// Данные игрока
private playerId: string | null = null;
private roomId: string | null = null;
private players: Map<string, PlayerState> = new Map();
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
this.eventBus = new EventBus<ClientEvents>();
}
/**
* Получить EventBus для модулей
*/
getEventBus(): EventBus<ClientEvents> {
return this.eventBus;
}
/**
* Подключение к серверу
*/
connect(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.state === ConnectionState.CONNECTED) {
resolve();
return;
}
this.state = ConnectionState.CONNECTING;
this.socket = new WebSocket(this.serverUrl);
this.socket.onopen = () => {
this.state = ConnectionState.CONNECTED;
this.reconnectAttempts = 0;
this.flushMessageQueue();
this.eventBus.emit('connection.open');
console.log('✅ Connected to server');
resolve();
};
this.socket.onclose = (event) => {
this.state = ConnectionState.DISCONNECTED;
this.eventBus.emit('connection.close', {
code: event.code,
reason: event.reason
});
console.log(`❌ Disconnected: ${event.reason}`);
this.attemptReconnect();
};
this.socket.onerror = (error) => {
this.eventBus.emit('connection.error', { error: error as Error });
reject(error);
};
this.socket.onmessage = (event) => {
this.handleMessage(event.data);
};
});
}
/**
* Отключение от сервера
*/
disconnect(): void {
if (this.socket) {
this.socket.close();
this.socket = null;
}
this.state = ConnectionState.DISCONNECTED;
this.playerId = null;
this.roomId = null;
this.players.clear();
}
/**
* Попытка переподключения
*/
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('⚠️ Max reconnect attempts reached');
return;
}
this.state = ConnectionState.RECONNECTING;
this.reconnectAttempts++;
const delay = this.reconnectDelay * this.reconnectAttempts;
console.log(`🔄 Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.connect().catch(() => {});
}, delay);
}
/**
* Обработка входящих сообщений
*/
private handleMessage(data: string): void {
try {
const message: ServerMessage = JSON.parse(data);
switch (message.type) {
case 'server:welcome':
this.playerId = message.payload.playerId;
this.roomId = message.payload.roomId;
message.payload.players.forEach(p => this.players.set(p.id, p));
this.eventBus.emit('game.welcome', message.payload);
break;
case 'server:player_joined':
this.players.set(message.payload.player.id, message.payload.player);
this.eventBus.emit('game.playerJoined', message.payload);
break;
case 'server:player_left':
this.players.delete(message.payload.playerId);
this.eventBus.emit('game.playerLeft', message.payload);
break;
case 'server:game_state':
message.payload.players.forEach(p => this.players.set(p.id, p));
this.eventBus.emit('game.state', message.payload);
break;
case 'server:damage':
this.eventBus.emit('combat.damage', message.payload);
break;
case 'server:death':
this.eventBus.emit('combat.death', message.payload);
break;
case 'server:chat':
this.eventBus.emit('chat.message', message.payload);
break;
case 'server:error':
this.eventBus.emit('error', message.payload);
break;
}
} catch (error) {
console.error('Error parsing message:', error);
}
}
// ═══════════════════════════════════════════════════════════
// МЕТОДЫ ОТПРАВКИ СООБЩЕНИЙ
// ═══════════════════════════════════════════════════════════
private send(message: ClientMessage): void {
if (this.state !== ConnectionState.CONNECTED) {
this.messageQueue.push(message);
return;
}
this.socket?.send(JSON.stringify(message));
}
private flushMessageQueue(): void {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()!;
this.send(message);
}
}
private createMessage<T extends ClientMessage>(
type: T['type'],
payload: T['payload']
): T {
return {
type,
payload,
timestamp: Date.now(),
messageId: Math.random().toString(36).substring(2)
} as T;
}
// ═══════════════════════════════════════════════════════════
// ПУБЛИЧНОЕ API ДЛЯ ИГРЫ
// ═══════════════════════════════════════════════════════════
join(playerName: string, roomId?: string): void {
this.send(this.createMessage('client:join', { playerName, roomId }));
}
move(x: number, y: number, direction: 'up' | 'down' | 'left' | 'right'): void {
this.send(this.createMessage('client:move', { x, y, direction }));
}
attack(targetId: string, skillId?: string): void {
this.send(this.createMessage('client:attack', { targetId, skillId }));
}
chat(message: string, channel: 'global' | 'room' | 'whisper' = 'room', targetId?: string): void {
this.send(this.createMessage('client:chat', { message, channel, targetId }));
}
// Геттеры
getPlayerId(): string | null { return this.playerId; }
getRoomId(): string | null { return this.roomId; }
getPlayers(): PlayerState[] { return Array.from(this.players.values()); }
getPlayer(id: string): PlayerState | undefined { return this.players.get(id); }
isConnected(): boolean { return this.state === ConnectionState.CONNECTED; }
}
export { GameClient, ClientEvents, ConnectionState };
Игровые модули
Подписка нескольких модулей на события
Архитектура с EventBus позволяет создавать независимые модули, которые подписываются на нужные им события. Это обеспечивает слабую связанность и легкую расширяемость.
💬 Chat Module
import { EventBus } from '../shared/EventBus';
import { ClientEvents } from '../client/GameClient';
/**
* ChatModule - модуль чата
* Подписывается на события чата и управляет сообщениями
*/
class ChatModule {
private eventBus: EventBus<ClientEvents>;
private messages: Array<{
id: string;
senderId: string;
senderName: string;
message: string;
channel: string;
timestamp: number;
}> = [];
private maxMessages: number = 100;
private unsubscribers: Array<() => void> = [];
constructor(eventBus: EventBus<ClientEvents>) {
this.eventBus = eventBus;
this.setupSubscriptions();
}
/**
* Настройка подписок на события
*/
private setupSubscriptions(): void {
// Многоразовая подписка на сообщения чата
const unsubChat = this.eventBus.on('chat.message', (data) => {
this.addMessage(data);
this.renderMessage(data);
});
this.unsubscribers.push(unsubChat);
// Подписка на вход игрока (для системных сообщений)
const unsubJoin = this.eventBus.on('game.playerJoined', (data) => {
this.addSystemMessage(`🟢 ${data.player.name} joined the game`);
});
this.unsubscribers.push(unsubJoin);
// Подписка на выход игрока
const unsubLeave = this.eventBus.on('game.playerLeft', (data) => {
this.addSystemMessage(`🔴 Player ${data.playerId} left (${data.reason})`);
});
this.unsubscribers.push(unsubLeave);
// Подписка на смерть (kill feed)
const unsubDeath = this.eventBus.on('combat.death', (data) => {
this.addSystemMessage(`💀 ${data.killerId} killed ${data.playerId}`);
});
this.unsubscribers.push(unsubDeath);
console.log('💬 ChatModule initialized');
}
private addMessage(data: ClientEvents['chat.message']): void {
this.messages.push({
id: Math.random().toString(36).substring(2),
...data,
timestamp: Date.now()
});if (this.messages.length > this.maxMessages) {
this.messages.shift();
}
}
private addSystemMessage(text: string): void {
this.addMessage({
senderId: 'system',
senderName: 'System',
message: text,
channel: 'system'
});
}
private renderMessage(data: ClientEvents['chat.message']): void {
console.log(`[${data.channel}] ${data.senderName}: ${data.message}`);
// Здесь можно обновить UI чата
}
getMessages(channel?: string): typeof this.messages {
if (!channel) return [...this.messages];
return this.messages.filter(m => m.channel === channel);
}
destroy(): void {
this.unsubscribers.forEach(unsub => unsub());
this.messages = [];
console.log('💬 ChatModule destroyed');
}
}
export { ChatModule };
⚔️ Combat Module
import { EventBus } from '../shared/EventBus';
import { ClientEvents } from '../client/GameClient';
/**
* DamageNumber - отображение урона
*/
interface DamageNumber {
id: string;
x: number;
y: number;
damage: number;
isCritical: boolean;
createdAt: number;
}
/**
* CombatStats - статистика боя
*/
interface CombatStats {
totalDamageDealt: number;
totalDamageReceived: number;
kills: number;
deaths: number;
criticalHits: number;
}
/**
* CombatModule - модуль боевой системы
* Обрабатывает урон, смерти, эффекты
*/
class CombatModule {
private eventBus: EventBus<ClientEvents>;
private playerId: string | null = null;
private damageNumbers: DamageNumber[] = [];
private stats: CombatStats = {
totalDamageDealt: 0,
totalDamageReceived: 0,
kills: 0,
deaths: 0,
criticalHits: 0
};
private unsubscribers: Array<() => void> = [];
private isRespawning: boolean = false;
constructor(eventBus: EventBus<ClientEvents>) {
this.eventBus = eventBus;
this.setupSubscriptions();
}
private setupSubscriptions(): void {
// Одноразовая подписка на welcome для получения playerId
this.eventBus.once('game.welcome', (data) => {
this.playerId = data.playerId;
console.log(`⚔️ CombatModule: Player ID set to ${data.playerId}`);
});
// Многоразовая подписка на урон
const unsubDamage = this.eventBus.on('combat.damage', (data) => {
this.handleDamage(data);
});
this.unsubscribers.push(unsubDamage);
// Подписка на смерть
const unsubDeath = this.eventBus.on('combat.death', (data) => {
this.handleDeath(data);
});
this.unsubscribers.push(unsubDeath);
// Подписка на критический урон с фильтром
const unsubCritical = this.eventBus.on(
'combat.damage',
(data) => {
this.playCriticalEffect(data);
},
{ filter: (data) => data.isCritical }
);
this.unsubscribers.push(unsubCritical);
// Подписка на большой урон (> 50) с высоким приоритетом
const unsubBigDamage = this.eventBus.on(
'combat.damage',
(data) => {
this.showBigDamageWarning(data);
},
{
filter: (data) => data.targetId === this.playerId && data.damage > 50,
priority: 100
}
);
this.unsubscribers.push(unsubBigDamage);
console.log('⚔️ CombatModule initialized');
}
private handleDamage(data: ClientEvents['combat.damage']): void {
// Обновляем статистику
if (data.attackerId === this.playerId) {
this.stats.totalDamageDealt += data.damage;
if (data.isCritical) this.stats.criticalHits++;
}
if (data.targetId === this.playerId) {
this.stats.totalDamageReceived += data.damage;
}
// Создаем floating damage number
this.createDamageNumber(data);
console.log(`⚔️ ${data.attackerId} → ${data.targetId}: ${data.damage} damage${data.isCritical ? ' (CRIT!)' : ''}`);
}
private handleDeath(data: ClientEvents['combat.death']): void {
if (data.killerId === this.playerId) {
this.stats.kills++;
this.showKillNotification(data.playerId);
}
if (data.playerId === this.playerId) {
this.stats.deaths++;
this.handleOwnDeath(data.respawnTime);
}
}
private createDamageNumber(data: ClientEvents['combat.damage']): void {
const damageNumber: DamageNumber = {
id: Math.random().toString(36),
x: Math.random() * 100, // Позиция будет получена из состояния игрока
y: Math.random() * 100,
damage: data.damage,
isCritical: data.isCritical,
createdAt: Date.now()
};
this.damageNumbers.push(damageNumber);
// Удаляем через 1 секунду
setTimeout(() => {
const index = this.damageNumbers.findIndex(d => d.id === damageNumber.id);
if (index !== -1) this.damageNumbers.splice(index, 1);
}, 1000);
}
private playCriticalEffect(data: ClientEvents['combat.damage']): void {
console.log('💥 CRITICAL HIT! Playing special effect...');
// Воспроизведение звука, частицы и т.д.
}
private showBigDamageWarning(data: ClientEvents['combat.damage']): void {
console.log(`⚠️ WARNING: Received ${data.damage} damage!`);
// Эффект тряски экрана, красная виньетка и т.д.
}
private showKillNotification(victimId: string): void {
console.log(`🏆 You killed ${victimId}!`);
}
private handleOwnDeath(respawnTime: number): void {
this.isRespawning = true;
console.log(`☠️ You died! Respawning in ${respawnTime / 1000}s...`);
setTimeout(() => {
this.isRespawning = false;
console.log('🔄 Respawned!');
}, respawnTime);
}
getStats(): CombatStats { return { ...this.stats }; }
getDamageNumbers(): DamageNumber[] { return [...this.damageNumbers]; }
isPlayerRespawning(): boolean { return this.isRespawning; }
destroy(): void {
this.unsubscribers.forEach(unsub => unsub());
console.log('⚔️ CombatModule destroyed');
}
}
export { CombatModule, CombatStats, DamageNumber };
🎒 Inventory Module
import { EventBus } from '../shared/EventBus';
import { ClientEvents } from '../client/GameClient';
interface Item {
id: string;
name: string;
type: 'weapon' | 'armor' | 'consumable' | 'material';
quantity: number;
}
/**
* InventoryModule - модуль инвентаря
* Подписывается на события смерти для дропа лута
*/
class InventoryModule {
private eventBus: EventBus<ClientEvents>;
private items: Map<string, Item> = new Map();
private gold: number = 0;
private unsubscribers: Array<() => void> = [];
constructor(eventBus: EventBus<ClientEvents>) {
this.eventBus = eventBus;
this.setupSubscriptions();
}
private setupSubscriptions(): void {
// Награда за убийство
const unsubDeath = this.eventBus.on('combat.death', (data) => {
// Если мы убили кого-то, получаем награду
this.handleKillReward(data);
});
this.unsubscribers.push(unsubDeath);
// Подписка на получение игрового состояния (для синхронизации)
const unsubWelcome = this.eventBus.once('game.welcome', () => {
this.initializeStarterItems();
});
console.log('🎒 InventoryModule initialized');
}
private initializeStarterItems(): void {
this.addItem({
id: 'starter_sword',
name: 'Starter Sword',
type: 'weapon',
quantity: 1
});
this.addItem({
id: 'health_potion',
name: 'Health Potion',
type: 'consumable',
quantity: 3
});
this.gold = 100;
console.log('🎒 Starter items added');
}
private handleKillReward(data: ClientEvents['combat.death']): void {
const goldReward = Math.floor(Math.random() * 50) + 10;
this.gold += goldReward;
console.log(`🪙 Received ${goldReward} gold for kill!`);
}
addItem(item: Item): void {
const existing = this.items.get(item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.set(item.id, { ...item });
}
}
getItems(): Item[] { return Array.from(this.items.values()); }
getGold(): number { return this.gold; }
destroy(): void {
this.unsubscribers.forEach(unsub => unsub());
console.log('🎒 InventoryModule destroyed');
}
}
export { InventoryModule, Item };
Полная интеграция
Объединение всех компонентов
🎮 Пример использования на клиенте
import { GameClient } from './client/GameClient';
import { ChatModule } from './modules/ChatModule';
import { CombatModule } from './modules/CombatModule';
import { InventoryModule } from './modules/InventoryModule';
/**
* Главный класс игры
*/
class Game {
private client: GameClient;
private chatModule: ChatModule;
private combatModule: CombatModule;
private inventoryModule: InventoryModule;
constructor() {
// Создаем клиент
this.client = new GameClient('ws://localhost:8080');
// Получаем EventBus и создаем модули
const eventBus = this.client.getEventBus();
this.chatModule = new ChatModule(eventBus);
this.combatModule = new CombatModule(eventBus);
this.inventoryModule = new InventoryModule(eventBus);
this.setupEventListeners();
}
private setupEventListeners(): void {
const eventBus = this.client.getEventBus();
// Подписываемся на успешное подключение (одноразово)
eventBus.once('connection.open', () => {
console.log('🎮 Connected! Joining game...');
this.client.join('Player_' + Math.floor(Math.random() * 1000));
});
// Подписываемся на приветствие (одноразово)
eventBus.once('game.welcome', (data) => {
console.log(`🎮 Welcome! You are ${data.playerId} in room ${data.roomId}`);
console.log(`👥 Players online: ${data.players.length}`);
});
// Подписываемся на обновление состояния (многоразово)
eventBus.on('game.state', (data) => {
this.updateGameState(data);
});
// Подписываемся на ошибки
eventBus.on('error', (data) => {
console.error(`❌ Error [${data.code}]: ${data.message}`);
});
// Подписываемся на отключение
eventBus.on('connection.close', (data) => {
console.log(`🔌 Disconnected: ${data.reason}`);
});
}
private updateGameState(data: { players: any[]; tick: number }): void {
// Обновление рендеринга, физики и т.д.
}
async start(): Promise<void> {
try {
await this.client.connect();
console.log('🎮 Game started!');
} catch (error) {
console.error('Failed to connect:', error);
}
}
stop(): void {
this.chatModule.destroy();
this.combatModule.destroy();
this.inventoryModule.destroy();
this.client.disconnect();
console.log('🎮 Game stopped');
}
// Публичные методы для UI
sendChatMessage(message: string): void {
this.client.chat(message);
}
attackTarget(targetId: string): void {
if (this.combatModule.isPlayerRespawning()) {
console.log('Cannot attack while respawning!');
return;
}
this.client.attack(targetId);
}
getCombatStats() { return this.combatModule.getStats(); }
getInventory() { return this.inventoryModule.getItems(); }
getGold() { return this.inventoryModule.getGold(); }
getChatMessages() { return this.chatModule.getMessages(); }
}
// Запуск игры
const game = new Game();
game.start();
// Для демонстрации
setTimeout(() => {
game.sendChatMessage('Hello, world!');
}, 2000);
// Остановка при закрытии
window.addEventListener('beforeunload', () => {
game.stop();
});
🖥️ Запуск сервера
import { GameServer } from './GameServer';
const PORT = 8080;
const server = new GameServer(PORT);
// Получаем EventBus для добавления дополнительных обработчиков
const eventBus = server.getEventBus();
// Логирование всех событий игроков
eventBus.on('player.*', (data) => {
console.log('[EVENT]', data);
});
// Одноразовая подписка на первого игрока
eventBus.once('player.join', (data) => {
console.log(`🎉 First player joined: ${data.playerName}`);
});
console.log(`🚀 Server running on ws://localhost:${PORT}`);
Сравнение типов подписок
Когда использовать on(), once() и фильтры
| Тип | Метод | Когда использовать | Пример |
|---|---|---|---|
| Многоразовая | on() |
Постоянная обработка событий | Обновление UI, логирование |
| Одноразовая | once() |
Событие должно быть обработано один раз | Инициализация, первое подключение |
| С фильтром | on({filter}) |
Обработка только определенных событий | Критический урон, большой урон |
| С приоритетом | on({priority}) |
Важный обработчик должен выполниться первым | Сохранение в БД до обновления UI |
| Wildcard | on('event.*') |
Подписка на группу событий | Логирование всех событий игрока |
Всегда сохраняйте функцию отписки, возвращаемую методами on() и once(). При уничтожении модуля вызывайте все отписки, чтобы избежать утечек памяти.
Итоги
Что мы изучили
📋 Ключевые концепции
- EventBus — паттерн для слабосвязанной коммуникации между модулями
- WebSocket — двусторонний протокол для real-time обмена данными
- Многоразовая подписка (on) — обработчик вызывается при каждом событии
- Одноразовая подписка (once) — обработчик автоматически удаляется после первого вызова
- Приоритеты — управление порядком вызова обработчиков
- Фильтры — условная обработка событий
- Wildcard подписки — подписка на группы событий по паттерну
🏗️ Архитектурные преимущества
Модули не знают друг о друге напрямую. ChatModule, CombatModule и InventoryModule работают независимо, общаясь только через EventBus.
Добавление нового модуля не требует изменения существующего кода. Просто подпишитесь на нужные события!
Каждый модуль можно тестировать изолированно, эмитя события через мок EventBus.