https://habr.com/ru/post/515934/- Python
- Node.JS
- Серверное администрирование
- TypeScript
- Игры и игровые приставки
Всем привет.
Этой весной я наткнулся на проект, в котором ребята научились запускать Dota 2 сервер версии 2014 года и, соответственно, играть на нем. Я большой фанат этой игры, и не смог пройти мимо уникальной возможности окунуться в свое детство.
Окунулся я очень глубоко, и так вышло что я написал Discord бота, который отвечает практически за весь функционал, который не поддерживается в старой версии игры, а именно матчмейкинг.
До всех нововведений с ботом лобби создавалось вручную. Собирали 10 реакций на сообщение и вручную собирали сервер, либо хостили локальное лобби.
Моя натура программиста не выдержала такое количество ручной работы, и за ночь я набросал самую простую версию бота, которая автоматически поднимала сервер, когда набиралось 10 человек.
Писать сходу решил на nodejs, потому что не очень люблю питон, ну и комфортнее себя чувствую в этой среде.
Это мой первый опыт написания бота для Discord, но оказалось все очень даже просто. Официальный npm модуль discord.js предоставляет удобный интерфейс для работы с сообщениями, сбором реакций и т.д.
Дисклеймер: все примеры кода являются «актуальными», то есть прошли несколько итераций переписывания по ночам.
Основа матчмейкинга — это «очередь», в которую помещаются игроки, которые хотят играть, и убираются, когда расхотели или нашли игру.
Так выглядит сущность «игрока». Изначально это был просто id пользователя в Discord, но в планах лаунчер/поиск игры с сайта, но обо всем по порядку.
export enum Realm {
DISCORD,
EXTERNAL,
}
export default class QueuePlayer {
constructor(public readonly realm: Realm, public readonly id: string) {}
public is(qp: QueuePlayer): boolean {
return this.realm === qp.realm && this.id === qp.id;
}
static Discord(id: string) {
return new QueuePlayer(Realm.DISCORD, id);
}
static External(id: string) {
return new QueuePlayer(Realm.EXTERNAL, id);
}
}
А вот интерфейс очереди. Тут вместо «игроков» используется абстракция в виде «группы». Для одиночного игрока группа состоит из него самого, а для игроков в группе, соответственно, из всех игроков группы.
export default interface IQueue extends EventEmitter {
inQueue: QueuePlayer[]
put(uid: Party): boolean;
remove(uid: Party): boolean;
removeAll(ids: Party[]): void;
mode: MatchmakingMode
roomSize: number;
clear(): void
}
Решил использовать события для обмена контекстом. Подходило под кейсы — по событию «найдена игра для 10 человек» можно и отправить в личные сообщения игрокам нужное сообщение, и выполнить основную бизнес логику — запустить таск для проверки готовности, подготовить лобби к запуску и так далее.
Для IOC я использую InversifyJS. Имею приятный опыт работы с этой библиотекой. Быстро и просто!
Очередей у нас на сервере несколько — добавились режими 1х1, обычный/рейтинговый, и пара кастомок. Поэтому есть singleton RoomService, который лежит между пользователем и поиском игры.
constructor(
@inject(GameServers) private gameServers: GameServers,
@inject(MatchStatsService) private stats: MatchStatsService,
@inject(PartyService) private partyService: PartyService
) {
super();
this.initQueue(MatchmakingMode.RANKED);
this.initQueue(MatchmakingMode.UNRANKED);
this.initQueue(MatchmakingMode.SOLOMID);
this.initQueue(MatchmakingMode.DIRETIDE);
this.initQueue(MatchmakingMode.GREEVILING);
this.partyService.addListener(
"party-update",
(event: PartyUpdatedEvent) => {
this.queues.forEach((q) => {
if (has(q.queue, (t) => t.is(event.party))) {
// if queue has this party, we re-add party
this.leaveQueue(event.qp, q.mode)
this.enterQueue(event.qp, q.mode)
}
});
}
);
this.partyService.addListener(
"party-removed",
(event: PartyUpdatedEvent) => {
this.queues.forEach((q) => {
if (has(q.queue, (t) => t.is(event.party))) {
// if queue has this party, we re-add party
q.remove(event.party)
}
});
}
);
}
(Лапша кода для представления, как примерно выглядят процессы)
Здесь я инициализирую очередь под каждый из реализованных режимов игры, а так же слушаю изменения «групп», чтобы подкорректировать очереди и избежать некоторых конфликтов.
Так, я молодец, я вставил куски кода, которые никак не относятся к топику, а теперь перейдем уже непосредственно к мачтмейкингу.
Рассмотрим кейс:
1) Пользователь хочет поиграть.
2) Для того, чтобы начать поиск, он использует Gateway=Discord, то есть ставит реакцию на сообщение:
3) Этот гейтвей идет в RoomService, и говорит «Пользователь из дискорда хочет войти в очередь, режим: нерейтинговая игра».
4) RoomService принимает просьбу гейтвея, и пихает в нужную очередь пользователя(точнее, группу пользователя).
5) Очередь при каждом изменении проверяет, хватает ли игроков для игры. Если можно — эмиттим событие:
private onRoomFound(players: Party[]) {
this.emit("room-found", {
players,
});
}
6) RoomService, очевидно, с радостью слушает каждую очередь в трепетном ожидании этого события. На вход мы получаем список игроков, формируем из них виртуальную «комнату», и, конечно же, эмиттим событие:
queue.addListener("room-found", (event: RoomFoundEvent) => {
console.log(
`Room found mode: [${mode}]. Time to get free room for these guys`
);
const room = this.getFreeRoom(mode);
room.fill(event.players);
this.onRoomFormed(room);
});
7) Вот мы и добрались до «высшей» инстанции — класса
Bot. В целом он занимается связью между гейтвеями(как это смешно на русском выглядит я не могу) и бизнес логикой матчмейкинга. Бот подслушивает событие, и приказывает DiscordGateway отослать всем пользователям проверку на готовность.
8) Если кто-то отклонил или не принял игру за 3 минуты, то мы НЕ возвращаем их в очередь. Всех остальных возвращаем в очередь и ждем, когда снова наберется 10 человек. Если все игроки приняли игру, то начинается интересная часть.
Конфигурация выделенного сервера
У нас игры хостятся на VDS c Windows server 2012. Из этого можно сделать несколько выводов:
- На него нет докера, что ударило меня в самое сердце
- Мы экономим на аренде
Стоит задача: с VPS на линуксе запускать процесс на VDS. Написал простой сервер на Flask. Да, не люблю питон, но что поделать — на нем написать этот сервер быстрее и проще.
Он выполняет 3 функции:
- Запуск сервера с конфигурацией — выбор карты, количества игроков для старта игры, и набор плагинов. Про плагины сейчас не буду писать — это отдельная история с литрами кофе по ночам вперемешку со слезами и вырванными волосами.
- Остановка/перезапуск сервера в случае неудачных подключений, которые мы можем обработать только вручную.
Тут все просто, примеры кода даже неуместны. Скрипт на 100 строчек
Итак, когда 10 человек собрались вместе и приняли игру, запущен сервер и все жаждут играть, в личные сообщения приходит ссылка на подключение к игре.
По нажатию ссылки игрока коннектит к игровому серверу, и дальше уже само все. Через ~25 минут виртуальная «комната» с игроками очищается.
Заранее извиняюсь за нескладность статьи, давно не писал сюда, да и кода слишком много, чтобы выделить важные участки. Лапша, короче.
Если увижу интерес к теме, то будет вторая часть — в ней будут мои мучения с плагинами для srcds(Source dedicated server), и, наверное, система рейтинга и мини-dotabuff, сайт со статистикой игр.
Немного ссылок:
- Наш сайт(статистика, таблица лидеров, небольшой лендос и скачивание клиента)
- Discord сервер