Введение


Недавно начал изучать язык программирования Go и для практики решил попробовать написать на нем клиент — серверное приложение. Так как я люблю компьютерные игры и в частности старую добрую «Змейку» в которую еще на стареньком черно белом телефоне играл то решил сделать именное ее. Да, вы не ослышались, речь пойдет о игре «Змейка» которая разделена на серверную и клиентскую часть общающиеся друг с другом по протоколу UPD.

> Код
> Скачать и поиграть — собирал только для amd64 и для Винды, Мака, Линукса

ToDo


Текст
Это моя первая публикация на хабре. Заранее спасибо за конструктивную критику. Просто я охладел к Go и сейчас изучаю Rust поэтому мне уже не интересно дорабатывать этот проект. Как напишу такую же онлайн игру на Rust постараюсь написать сюда статью об этом. Ну а если кто — то подскажет еще какие то варианты для моего ToDo то впишу их сюда с указанием автора. Потом реализую все это в проекте на Rust.
  1. Можно сделать пуш соcтояния игры клиенту а не постоянно запрашивать его.
  2. Можно отправлять только соcтояние еды и змейки клиенту так как состояние стенок остается неизменным.
  3. Можно было бы сделать проверку столкновения со стенками через проверку крайних значений а не тупым перебором все значений.
  4. Сделать проверку столкновения с хвостом в одном цикле с передвижением змейки.
  5. Добавить мютексы на код отвечающий за запись данных игры в матрицу и код проверяющий играющих на данный момент клиентов.
  6. Да и вообще, вынести проверку живых на данный момент клиентов в отдельный поток и это сразу бы устранило бы этот баг: Если у нас только один единственный клиент и он вдруг отключился то сервер не сообщит об этом и не убьет инстанс этого клиента.
  7. Починить биндинг констант для клавиш. Сейчас их название не соответствует действиям которые они вызывают.
  8. Сделать расчет потери пакетов для комманд на основе их айди


Скриншоты игры и результат pprof


Осторожно! Много картинок

pprof cpu



Скриншот сервера




ТЗ для клиента игры. Да, я сам для себя написал ТЗ


Текст
1) Обработка пользовательского ввода.
1.1) Считать нажатую клавишу
1.2) Создать команду соответствующую нажатой клавише.
1.3) Если команда не была распознана на предыдущем шаге то пропустить нижележащие шаги.
1.4) Отправить команду на сервер 4 раза подряд с одинаковым идентификатором с надеждой на то что хотя бы 1 пакет из 4 дойдет.
2) Цикл отображения.
2.1) Запросить текущее состояние игры с сервера.
2.2) Получить текущее состояние игры с сервера.
2.3) Отобразить текущее состояние игры.
2.4) Выдержать таймаут.

ТЗ сервера


Текст
1) Обработка подключения нового клиента.
1.1) Создать новую игровую сессию для нового клиента.
1.2) Напечатать в консоль айпи адрес нового клиента.
1.3) Добавить клиента в список клиентов сервера.
2) Обработка команды присланной клиентом
2.1) Если команда с данным идентификатором уже была обработана то ничего не делать.
2.2) Если пришла новая команда то в зависимости от нее изменить состояние игры.
2.2.1) Если была команда движения вверх то повернуть змейку вверх
2.2.2) Если была команда движения вниз то повернуть змейку вниз
2.2.3) Если была команда движения вправо то повернуть змейку вправо.
2.2.4) Если была команда движения влево то повернуть змейку влево.
2.2.5) Если команда не была обработана на предыдущих шагах то ничего не делать.
3) Обработка запроса состояния игры
3.1) Выбрать подходящую сессию игры по айпи адресу клиента.
3.2) Считать текущее состояние игровой сессии клиента.
3.3) Отправить текущее состояние игры клиенту.
4) Игровой цикл.
4.1) Проверить столкновение змейки с едой
4.1.1) Если голова змейки столкнулась с едой.
4.1.1.1) Увечить длину змейки на один квадрат
4.1.1.2) С генерировать еду в новом месте.
4.1.2) иначе ничего не делать.
4.2) Проверить столкновение змейки со стеной
4.2.1) Если змейка столкнулось со стеной перевести игру в начальное состояние
4.2.2) Если змейка не столкнулась со стеной то ничего не делать
4.3) Проверить столкновение головы змейки с ее хвостом.
4.3.1) Если змейка столкнулась с хвостом то перевести игру в начальное состояние.
4.3.2) Если столкновение не было то ничего не делать.
4.3) Передвинуть змейку.
5) Обработка отключения клиента.
5.1) Если клиент не присылал никаких запросов более 5 секунд то
5.1.1) Остановить игровую сессию клиента.
5.1.2) Вывести на консоль ай пи адрес отключившегося клиента.
5.1.3) Удалить адрес клиента из списка текущих клиентов с которыми работает сервер.

Код клиента


Тут все максимально просто. У нас есть структура отвечающая за общение по протоколу UPD

Код
package dal

import (
	"fmt"
	"net"
)

type IUdpClient interface {
	Read(p []byte) (int, error)
	Write(p []byte) (int, error)
	Close() error
}

type udpClient struct {
	connection     *net.UDPConn
	localAddress   *net.UDPAddr
	remouteAddress *net.UDPAddr
	timeOut        uint
}

func NewUdpClient(clientIp, serverIp string, timeOut uint) (IUdpClient, error) {
	client, e := net.ResolveUDPAddr("udp4", clientIp)
	if e != nil {
		return nil, e
	}
	server, e := net.ResolveUDPAddr("udp4", serverIp)
	if e != nil {
		return nil, e
	}
	conn, err := net.ListenUDP("udp", client)
	if err != nil {
		return nil, err
	}
	return udpClient{conn, client, server, timeOut}, nil
}

func (c udpClient) Read(p []byte) (int, error) {
	//c.connection.SetReadDeadline(time.Now().Add(time.Second * time.Duration(c.timeOut)))
	i, a, e := c.connection.ReadFromUDP(p)
	if e != nil {
		return 0, e
	}
	if a.String() != c.remouteAddress.String() {
		return 0, fmt.Errorf("Unnown addres %v", a)
	}
	return i, nil
}

func (c udpClient) Write(p []byte) (int, error) {
	return c.connection.WriteToUDP(p, c.remouteAddress)
}

func (c udpClient) Close() error {
	return c.connection.Close()
}


1) В одной рутине мы читаем нажатые игроком клавиши и отправляем на сервер команды

Код
go func() {
		var id uint64 = 1
		for {
			key := screen.ReadKey()
			code := parseKeyCode(key)
			if code > 0 {
					id++
				for i := 0; i < 4; i++ {
					e := sendCommandToServer(Command{id, code})
					if e != nil {
						logger.Println(e.Error())
					}
				}
			}
		}
	}()


2) В другой рутине мы считываем пришедшее с сервера состояние и отображаем его клиенту.

Код
go func() {
		for {
			s, e := requestStateFromServer()
			if e != nil {
				l.Println(e.Error())
				continue
			}
			showState(s)
		}
	}()


Код сервера


Тут тоже все предельно просто.

Читаем пришедшие данные и отправляем результат.

Код

func (this server) listen() {
	in := make(chan inValue, 100)
	out := make(chan outValue, 100)
	defer func() {
		this.listener.Close()
		this.dispatcher.Close()
		close(in)
		close(out)
	}()
	for i := 0; i < COUNT_THREADS_IN_POOL; i++ {
		go func() {
			for input := range in {
				bytes, err := this.dispatcher
.Dispatch(input.data[:input.count], fmt.Sprintf("%v", input.remoteaddr))
				this.pool.Put(input.data)
				if err != nil {
					fmt.Printf("Error on dispathing %v\n", err)
					continue
				}
				if len(bytes) < 1 {
					fmt.Print("Empty result\n")
					continue
				}
				out <- outValue{bytes,input.remoteaddr}
			}
		}()
	}
	go func() {
		for result := range out {
			_, err := this.listener.Write(result.data, result.remoteaddr)
			if err != nil {
				fmt.Printf("Couldn't send response - %v \n", err)
			}
		}
	}()
	for {
		data := this.pool.Get().([]byte)
		count, remoteaddr, err := this.listener.Read(data)
		if err != nil {
			fmt.Printf("Error on reading from listener %v\n", err)
			continue
		}
		in <- inValue{count:count,remoteaddr:remoteaddr, data:data}
	}
}


Наш диспетчер просто выбирает подходящего клиенты по его ай пи.

Код

func (this *dispatcher) Dispatch(data []byte, clientId string) ([]byte, error) {
	this.checkAliveClients()
	c, ok := this.clients[clientId]
	if !ok {
		fmt.Printf("Connected new client %s\n", clientId)
		this.clients[clientId] = this.factory.CreateClient()
		c = this.clients[clientId]
	}
	c.UpdateLastActiveTime()
	return c.Accept(data)
}


Если пришла команда то изменяем направление змейки.

Код

func (game *game) Logic(timeDeltaInNanoSeconds int64) {
	game.timeBuffer += timeDeltaInNanoSeconds
	select {
	case command := <-game.commandChannel:
		switch command {
		case Up:
			game.snake.Go(bll.UpDirection)
		case Down:
			game.snake.Go(bll.DownDirection)
		case Left:
			game.snake.Go(bll.LeftDirection)
		case Right:
			game.snake.Go(bll.RightDirection)
		}
	default:
	}
	if game.timeBuffer >= timeDeltaInNanoSecondsAfterThatSnakeMoves {
		game.snake.Move()
		game.snake.TryEat(game.food)
		if game.snake.IsHit(game.frame) || game.snake.IsHitTail() {
			game.snake.Reset()
		}
		game.timeBuffer -= timeDeltaInNanoSecondsAfterThatSnakeMoves
	}
}


Если пришел запрос состояния игры то отправляем новое состояние.

Код

func (game *game) Draw() [][]rune {
	game.screen.Clear()
	game.frame.Draw()
	game.food.Draw()
	game.snake.Draw()
	return game.screen.Data()
}


Выводы


Когда в Go добавят дженерики и вменяемые enum то где то через год после этого посмотрю на ЯП еще раз. Пока как то Rust намного более привлекательным выглядит. Это мое личное ИМХО и я никоим образом не хотел задеть чувства гоферов. Спасибо за понимание.

Комментарии (6)


  1. x88
    23.07.2018 03:16

    Язык не знаю, но осуждаю.

    Код комментировать даже не буду.


    1. VanquisherWinbringer Автор
      23.07.2018 11:09

      1) Код настолько банальный и простой что я и не знаю что тут комментировать. Что именно вам было не понятно?
      2) Ну по поводу языка это было мое личное ИМХО. Да и вообще, UDP в принцыпе вешь низкоуровневая и мне надо было сразу смотреть на С/С++/Rust а не городить огород с Go. Это тоже мое ИМХО. Да и без моего ИМХО если говорить о обьективных фактах то, судя по бенчмарка которые я смотрел в интернете, Go по скорости такой же как Java/C#.


      1. kuftachev
        23.07.2018 11:33

        На счёт скорости Go и Java/C#, как говорит Шипилев, большинство людей обычно не представляют что они тестируют.


        Например, если взять задачу, где нужно будет часто и много выделять память, но проводить замеры недостаточно долго, чтобы нужно было ее чистить, то Java и C++ может обогнать, если на нем не писать свой алокатор под эту задачу.


        Go нереально крут тем, что он может из коробки асинхронно раскидывать задачи на много потоков, которые мапятся на процессоры и за счёт того, что го-рутины легковесные, он может спокойно перекидывать их между процессорами.


        По сути, это можно делать на C++, но не думаю, что очень много людей это сделает без ошибок.


        В общем, как говорил Ленин, не все цитаты в интернете подлинные (это про бенчмарки), а Go для своих задач идеальный, просто нужно научиться думать по другому и не искать абстракции в черной комнате, если их там нет.


  1. Shaz
    23.07.2018 10:32

    Потерять 25% пакетов на локалхосте…


    1. VanquisherWinbringer Автор
      23.07.2018 11:04

      В ToDo листе же написанно что дело в неправильном алгоримте расчета потери пакетов. Надо его делать на основе инкрементируемоего айди пакета. Сделаю это в версии на Rust.


  1. anjensan
    23.07.2018 12:13

    Когда в Go добавят дженерики и вменяемые enum то где то через год после этого посмотрю на ЯП еще раз.
    Ну вот вы и обрекли свою статью на минусы :D