🎮 Game Development • Real-time Architecture

EventBus + WebSockets

Полное руководство по созданию архитектуры реального времени для многопользовательских игр

📡 EventBus
🔌 WebSocket
🎯 Real-time
📚

Введение

Что такое EventBus и WebSockets

При разработке многопользовательских игр и приложений реального времени ключевую роль играют два паттерна: EventBus (шина событий) и WebSockets. Вместе они образуют мощную архитектуру для обмена сообщениями между клиентами и сервером.

Общая архитектура
👤 Клиент
🔌 WebSocket
📡 EventBus
🎮 Game Modules

🔌 WebSocket

WebSocket — протокол для двустороннего обмена данными между клиентом и сервером в режиме реального времени. В отличие от HTTP, соединение остается открытым, что позволяет мгновенно отправлять и получать сообщения.

📡 EventBus

EventBus (шина событий) — паттерн проектирования, который позволяет компонентам системы общаться друг с другом через события, не зная напрямую друг о друге. Это обеспечивает слабую связанность (loose coupling) между модулями.

💡 Зачем нужна комбинация?

WebSocket отвечает за транспорт данных между клиентом и сервером, а EventBus — за маршрутизацию событий внутри приложения. Это разделение позволяет легко менять транспорт (WebSocket → HTTP long-polling → SSE) без изменения бизнес-логики.

📡

Реализация EventBus

Полнофункциональная шина событий

🎯 Основные возможности

  • Многоразовая подписка (on) — обработчик вызывается при каждом событии
  • Одноразовая подписка (once) — обработчик вызывается только один раз
  • Отписка (off) — удаление обработчика
  • Приоритеты — порядок вызова обработчиков
  • Фильтрация событий — условная обработка
  • Wildcard подписки — подписка на группы событий
  • Типизация — полная поддержка TypeScript
📄 EventBus.ts 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.js JavaScript
/**
 * 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

🎮 Примеры подписок TypeScript
// Создаем экземпляр 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 Сервер и Клиент

Реализация для многопользовательской игры

📁 Структура проекта

📁 game-project/
📁 shared/
📄 EventBus.ts
📄 GameEvents.ts
📄 Protocol.ts
📁 server/
📄 GameServer.ts
📄 PlayerManager.ts
📄 RoomManager.ts
📁 client/
📄 GameClient.ts
📄 NetworkManager.ts
📁 modules/
📄 ChatModule.ts
📄 CombatModule.ts
📄 InventoryModule.ts

🌐 Протокол сообщений

📄 shared/Protocol.ts TypeScript
/**
 * Типы сообщений между клиентом и сервером
 */

// Базовое сообщение
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 Сервер

📄 server/GameServer.ts TypeScript
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 Клиент

📄 client/GameClient.ts TypeScript
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 позволяет создавать независимые модули, которые подписываются на нужные им события. Это обеспечивает слабую связанность и легкую расширяемость.

Архитектура модулей
📡 EventBus
💬 ChatModule
⚔️ CombatModule
🎒 InventoryModule

💬 Chat Module

📄 modules/ChatModule.ts TypeScript
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

📄 modules/CombatModule.ts TypeScript
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

📄 modules/InventoryModule.ts TypeScript
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 };
🔗

Полная интеграция

Объединение всех компонентов

🎮 Пример использования на клиенте

📄 main.ts - Точка входа клиента TypeScript
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();
});

🖥️ Запуск сервера

📄 server/index.ts TypeScript
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 подписки — подписка на группы событий по паттерну

🏗️ Архитектурные преимущества

✨ Слабая связанность (Loose Coupling)

Модули не знают друг о друге напрямую. ChatModule, CombatModule и InventoryModule работают независимо, общаясь только через EventBus.

🔧 Легкая расширяемость

Добавление нового модуля не требует изменения существующего кода. Просто подпишитесь на нужные события!

🧪 Тестируемость

Каждый модуль можно тестировать изолированно, эмитя события через мок EventBus.

📁 Готовая структура проекта

📁 game-project/
📁 shared/
📄 EventBus.ts — Шина событий
📄 Protocol.ts — Типы сообщений
📁 server/
📄 GameServer.ts — WebSocket сервер
📄 index.ts — Точка входа сервера
📁 client/
📄 GameClient.ts — WebSocket клиент
📁 modules/
📄 ChatModule.ts — Модуль чата
📄 CombatModule.ts — Модуль боя
📄 InventoryModule.ts — Модуль инвентаря
📄 main.ts — Точка входа клиента