Многопользовательская игра на Go через telnet +31


Всем привет! Меня зовут Олег и я SRE. В какой-то момент мне захотелось улучшить свои навыки программирования на Go и написать маленькую многопользовательскую игру.

Заниматься веб-анимацией или писать приложение под мобильную платформу мне не хотелось, поэтому я решил в качестве клиента использовать любимый инструмент сисадминов — telnet.

Вот что получилось:

image

Выбор технологий


Telnet


Про приложения, использующие telnet, я слышал уже давно. Например, небольшой отрывок из «Звёздных войн»:

telnet towel.blinkenlights.nl

Но, почему telnet, спросите Вы? Ведь netcat намного круче и современнее добавят другие. Не все знают, но telnet — это не только утилита для установления TCP-соединения. Это целый протокол, который позволяет, например, отправлять на сервер введённые данные без нажатия Enter (line feed ANSII).

Поскольку мы хотим использовать стрелочки для управления машинкой и мы не хотим каждый раз нажимать Enter, чтобы отправить последовательность символов на сервер — telnet прекрасно подходит для решения подобной задачи.

Как вариант можно было использовать

stty -icanon && nc <host> <port>

но это, прямо скажем, костыль.

Go


Почему Go? Помимо того, что я хотел подтянуть свои навыки, в Go есть Goroutines, которые идеальны для написания «многопоточных» приложений. А поскольку мы хотим чтобы много людей играло параллельно — причин не использовать Go не было.

Так же я не так давно прочитал вот эту статью и мне было интересно попробовать визуализировать Goroutines в приложении.

Game design


Игровой процесс


Игра представляет собой мультиплеер до 5 человек в раунде. Если количество игроков будет меньше — оставшиеся слоты будут представлены ботами.

Вам нужно, играя машинкой, выжить и уничтожить соперников. Игрок может собирать бонусы или разбрасывать бомбы, которые накапливаются с течением времени.

Система повреждений


Один из интересных и спорных моментов — система повреждений. Мы решили учитывать разницу в скорости и часть машины, на которую пришелся удар.

Скорость может варьироваться. При ударе она сбрасывается до 1. При безаварийной езде через некоторое время она увеличивается до 5.

Теперь рассмотрим ситуацию, когда один игрок догоняет другого и врезается в заднюю часть автомобиля. Формулы подсчета урона для игроков будут следующие:

if player.Car.Borders.intersects(&opponent.Car.Borders) {
	switch player.Car.Borders.nextTo(&opponent.Car.Borders, 0) {
	case LEFT:
		// Игрок слева
		switch player.Car.Direction {
		...
		case RIGHT:
			// Удар сзади
			player.Health -= DAMAGE_BACK * (maxSpeed - player.Car.Speed)
			...
	case RIGHT:
		// Игрок справа
		switch player.Car.Direction {
		case RIGHT:
			// Удар спереди
			player.Health -= DAMAGE_FRONT * player.Car.Speed
			...
	...
}

То есть если игрок А двигался со скоростью 4 и его догнал игрок Б со скоростью 5, игрок А потеряет 2*(5-4) = 2. Однако, атакующий игрок потеряет 4*5 = 20.

Самым же сильным повреждением окажется удар сбоку DAMAGE_SIDE = 6 и может доходить до 24.

Разработка


Хочу ещё раз напомнить, что игра создавалась в качестве учебного упражнения. Если есть у Вас есть предложения по улучшению — с радостью их приму во внимание.

Исходный код находится на github, но давайте пробежимся по основным моментам:

Telnet


Как я уже упомянул, нам требуется поддержка telnet-протокола. Для этого на стороне приложения нам требуется «поздороваться» с клиентом при помощи последовательности байт:

telnetOptions := []byte{
	255, 253, 34, // IAC DO LINEMODE
	255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE
	255, 251, 1, // IAC WILL ECHO
}
_, err := conn.Write(telnetOptions)

и считать ответ (начинается с 250).

После этого telnet-клиент будет работать с поддержкой протокола, что нам и было нужно. Подробнее можно посмотреть в исходном коде.

Данные взяты из RFC854.

Раунды


Когда новый игрок подключается к игре, мы должны добавить его в раунд. Критерии могут быть самыми разными — для нас — просто наличие свободного места. Раундом здесь выступает массив игроков. Вся структура выглядит вот так:

type Round struct {
	Players         []Player
	FrameBuffer     Symbols
	...
}

Если недостаточно игроков — добавляем ботов.

Каналы


Каналы в Go являются очень удобным средством синхронизации между Gorountine. Мы их используем для подготовки раунда и распределению игроков:

func (p *Player) checkBestRoundForPlayer(compileRoundChannel chan Round) {
	foundRoundForUser := false
	for i := 0; i < len(compileRoundChannel); i++ {
		select {
		case r := <-compileRoundChannel:
			// Есть ожидающий раунд
			if len(r.Players) < maxPlayersPerRound && !p.searchDuplicateName(&r) {
				...
				r.Players = append(r.Players, *p)
				compileRoundChannel <- r
				foundRoundForUser = true
				break
			} else {
				compileRoundChannel <- r
			}
		default:
		}
	}
	if !foundRoundForUser {
		// Создаём новый раунд
		...
		r := Round{...}
		r.Players = append(r.Players, *p)
		compileRoundChannel <- r
	}
}

Заметьте, что не требуется использование Mutex, несмотря на то, что данная функция выполняется в отдельных Goroutine. Каналы в Go не позволят Вам вычитать объект несколько раз, так что модификация одного объекта исключена в этом случае.

FrameBuffer


Все игроки без исключения должны получать полную информацию о происходящем в игре. Так давайте же отправлять всем одно и то же изображение. Изначально тип FrameBuffer был просто []byte, но в какой-то момент мы решили добавить Emoji, чтобы сделать игру красочнее. А поскольку каждый такой символ занимает больше 1го байта — по факту Symbols — это массив массивов байт.

type Symbol struct {
	Color int
	Char  []byte
}
type Symbols []Symbol

Итак, вот примерный алгоритм генерации содержимого FrameBuffer:

  • Копируем подготовленную заранее карту
  • Добавляем данные игрока (здоровье, количество бомб...)
  • Добавляем бонус и бомбы на карту (если есть)
  • Добавляем машины

Боты


Искусственного интеллекта тут, конечно же нет. Но каким-то базовым действиям боты обучены:

  • Преследовать игроков (не ботов)
  • Если рядом есть бонус — собрать
  • Если на пути препятствие (другой игрок) — объехать

В целом боты довольно надоедливые, так что играть становится интереснее.

Визуализация


Нам нужен gotrace, который собственно, и выполняет всю работу за нас. Там полно примеров и подробное описание, как запустить Docker для трассировки рутин в нашем приложении. Что получилось, можно увидеть на коротком видео:

Тут мы видим несколько каналов и целую пачку Goroutine, которые заведуют игровой логикой и каналы между ними через которые они обмениваются информацией.

Одно из основных кредо Go тут полностью соблюдено:

Don't communicate by sharing memory, share memory by communicating.

Как поиграть?


К сожалению, на Windows какие-то проблемы с emoji в терминале. У меня не было достаточно мотивации разбираться, так как я пользуюсь MacOS и Linux. Но если кто-то знает режим совместимости или ещё что-то — буду рад совету.

Для всех остальных:

telnet protury.info 4242


Итог


  • Goroutine — отличный инструмент XXI века. Реализация параллельного исполнения функций не несёт практически никаких накладных расходов для разработчика
  • Визуализация Goroutine — красивая абстракция. К сожалению, не очень хорошо работает на приложениях, чуть сложнее «Hello, World!» и завязана на старой версии Go 1.5 в контейнере
  • Даже древние протоколы, такие как Telnet, могут удивлять своей продуманностью в 2017 году
  • Go — прекрасно подходит не только для веб-приложений. Он ещё более хорош для написания backend-сервисов
  • Даже в текстовом режиме терминала можно отображать красочные объекты — нужно лишь использовать символы из расширенного ASCII или Emoji

Бонус


Вообще-то — это вторая игра, написанная мною. Первая была однопользовательская, но тоже очень интересная:



Поиграть:

telnet protury.info 4243

Спасибо


Хотелось бы сказать спасибо компании InnoGames, которая предоставляет InnoDay — целый день в неделю, когда можно заниматься подобными вещами, также моим коллегам, которые мне всячески помогали — Павел Усов и Kajetan Staszkiewicz. А также всем, кто прочел до конца!
-->


К сожалению, не доступен сервер mySQL