NanoMMO на Go и Canvas [Сервер]


    Каждый программист должен написать свою cms, framework, mmorpg. Именно этим мы и займемся.
    Демо

    Условности

    Для понимая материала нужно либо знать Go, либо любой другой си-подобный язык, а также представлять себе как писать на js.
    Вводный тур по Go
    Туториал по канвасу
    Основная цель данного материала — привести в порядок мои собственные мысли. Не в коем случае не стоит рассматривать изложенное здесь как пример, с которого можно бездумно копировать.


    Постановка задачи

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

    Связь между клиентом и сервером будет организована через вебсокеты, значит мы можем передавать только строки, а также нам придется мириться с неторопливостью TCP. Для простоты реализации и отладки, будем обмениваться сообщениями в json'е.

    Первая пришедшая мне в голову мысль — напишу сначала клиента, при помощи которого впоследствии можно будет тестировать сервер. Собственно, так я и сделал. Но мы поступим по-другому; дальше станет понятно почему.

    Сервер

    Наш сервер будет выполнять следующие задачи:
    • Принимать команды от клиентов
    • Оповещать подключенных клиентов об изменениях игрового мира
    • Выполнять игровой цикл, изменяя состояние мира

    Под миром мы будем подразумевать список подключенных персонажей и только. Ни карты, ни препятствий — только игроки. Единственное, что будут уметь делать персонажи, это перемещаться с определенной скоростью к заданной точке.
    Тогда структура нашего персонажа будет выглядеть следующим образом:
    /* point.go && character.go */ 
    ...
    type Point struct {
    	X, Y float64
    }
    ...
    type Character struct {
    	Pos, Dst Point   //Текущее положение и точка назначения
    	Angle    float64 //Угол поворота
    	Speed    uint    //Максимальная скорость
    	Name     string
    }
    ...
    

    Напомню что в go, поля написанные с большой буквы являются экспортируемыми (публичными), а при сериализации объекта в json добавляются только экспортируемые поля. (Несколько раз наступал на эти грабли, не понимая почему с виду правильный код не работает. Оказывается поля были написаны с маленькой буквы).

    На клиенте нам нужно будет синхронизировать данные. Чтобы не писать кучу кода, вида character.x = data.X для всех текущих и будущих полей, мы будем рекурсивно проходить по полям данных от сервера и, при совпадении названий, присваивать их клиентским объектам. Но поля в go написаны с большой буквы. Поэтому мы примем соглашение об именовании полей в js в стиле go. Именно по этой причине мы начали с рассмотрения сервера.

    Инициализация приложения и главный цикл
    /* main.go */
    package main
    
    import (
    	"fmt"
    	"time"
    )
    
    const (
    	MAX_CLIENTS = 100 //Столько клиентов мы готовы обслуживать одновременно
    	MAX_FPS     = 60
    	// Время в go измеряется в наносекундах
    	// time.Second это количество наносекунд в секунде
    	FRAME_DURATION = time.Second / MAX_FPS
    )
    
    // Ключами этого хэша будут имена персонажей
    var characters map[string]*Character
    
    func updateCharacters(k float64) {
    	for _, c := range characters {
    		c.update(k)
    	}
    }
    
    func mainLoop() {
    	// Мы хотим чтобы персонажи двигались независимо от скорости железа и
    	// загруженности системы.
    	// При помощи этого коэффицента, мы привязываем движение объектов ко времени
    	var k float64
    	for {
    		frameStart := time.Now()
    
    		updateCharacters(k)
    
    		duration := time.Now().Sub(frameStart)
    		// Если кадр просчитался быстрее, чем необходимо подождем оставшееся время
    		if duration > 0 && duration < FRAME_DURATION {
    			time.Sleep(FRAME_DURATION - duration)
    		}
    		ellapsed := time.Now().Sub(frameStart)
    		// Коэффициент это отношение времени, потраченного на обработку одного кадра к секунде
    		k = float64(ellapsed) / float64(time.Second)
    	}
    }
    
    
    func main() {
    	characters = make(map[string]*Character, MAX_CLIENTS)
    	fmt.Println("Server started at ", time.Now())
    
    	// Запускаем обработчик вебсокетов
    	go NanoHandler()
    	mainLoop()
    }
    
    

    В методе Character.update мы передвигаем персонажа, если есть куда идти:
    /* point.go */
    ...
    // Числа с плавающей точкой не стоит сравнивать напрямую,
    // лучше проверять их разность
    func (p1 *Point) equals(p2 Point, epsilon float64) bool {
    	if epsilon == 0 {
    		epsilon = 1e-6
    	}
    	return math.Abs(p1.X-p2.X) < epsilon && math.Abs(p1.Y-p2.Y) < epsilon
    }
    ...
    /* chacter.go */
    ...
    func (c *Character) update(k float64) {
    	// Если расстояние между текущим положением и точкой назначения
    	// меньше максимального расстояния, которое персонаж может пройти за этот кадр
    	// или персонаж вообще не хочет никуда идти,
    	// просто перемещаем его в точку назначения
    	if c.Pos.equals(c.Dst, float64(c.Speed)*k) {
    		c.Pos = c.Dst
    		return
    	}
    	// Ура! Нам пригодился школьный курс геометрии и тригонометрии
    	// Впрочем мы могли бы обойтись без угла и [ко]синусов, но угол нам будет нужен в перспективе
    	// В качестве домашнего задания перепишите этот метод без использования тригонометрии
    	lenX := c.Dst.X - c.Pos.X
    	lenY := c.Dst.Y - c.Pos.Y
    	c.Angle = math.Atan2(lenY, lenX)
    	dx := math.Cos(c.Angle) * float64(c.Speed) * k
    	dy := math.Sin(c.Angle) * float64(c.Speed) * k
    	c.Pos.X += dx
    	c.Pos.Y += dy
    }
    ...
    

    Теперь перейдем непосредственно к вебсокетам.
    /* nano.go */
    package main
    
    import (
    	"code.google.com/p/go.net/websocket"
    	"fmt"
    	"io"
    	"net/http"
    	"strings"
    )
    
    const (
    	MAX_CMD_SIZE  = 1024
    	MAX_OP_LEN    = 64
    	CMD_DELIMITER = "|"
    )
    
    // Ключи — адреса клиентов вида ip:port
    var connections map[string]*websocket.Conn
    
    // Эту структуру мы будем сериализовать в json и передавать клиенту
    type packet struct {
    	Characters *map[string]*Character
    	Error      string
    }
    
    //Настраиваем и запускаем обработку сетевых подключений
    func NanoHandler() {
    	connections = make(map[string]*websocket.Conn, MAX_CLIENTS)
    	fmt.Println("Nano handler started")
    	//Ссылки вида ws://hostname:48888/ будем обрабатывать функцией NanoServer
    	http.Handle("/", websocket.Handler(NanoServer))
    	//Слушаем порт 48888 на всех доступных сетевых интерфейсах
    	err := http.ListenAndServe(":48888", nil)
    	if err != nil {
    		panic("ListenAndServe: " + err.Error())
    	}
    }
    
    //Обрабатывает сетевое подключения
    func NanoServer(ws *websocket.Conn) {
    	//Памяти выделили под MAX_CLIENTS, поэтому цинично игнорируем тех, на кого не хватает места
    	if len(connections) >= MAX_CLIENTS {
    		fmt.Println("Cannot handle more requests")
    		return
    	}
    
    	//Получаем адрес клиента, например, 127.0.0.1:52655
    	addr := ws.Request().RemoteAddr
    
    	//Кладем соединение в таблицу
    	connections[addr] = ws
    	//Создаем нового персонажа, инициализируя его некоторыми стандартными значениями
    	character := NewCharacter()
    
    	fmt.Printf("Client %s connected [Total clients connected: %d]\n", addr, len(connections))
    
    	cmd := make([]byte, MAX_CMD_SIZE)
    	for {
    		//Читаем полученное сообщение
    		n, err := ws.Read(cmd)
    
    		//Клиент отключился
    		if err == io.EOF {
    			fmt.Printf("Client %s (%s) disconnected\n", character.Name, addr)
    			//Удаляем его из таблиц
    			delete(characters, character.Name)
    			delete(connections, addr)
    			//И оповещаем подключенных клиентов о том, что игрок ушел
    			go notifyClients()
    			//Прерываем цикл и обработку этого соединения
    			break
    		}
    		//Игнорируем возможные ошибки, пропуская дальнейшую обработку сообщения
    		if err != nil {
    			fmt.Println(err)
    			continue
    		}
    
    		fmt.Printf("Received %d bytes from %s (%s): %s\n", n, character.Name, addr, cmd[:n])
    
    		//Команды от клиента выглядят так: operation-name|{"param": "value", ...}
    		//Поэтому сначала выделяем операцию
    		opIndex := strings.Index(string(cmd[:MAX_OP_LEN]), CMD_DELIMITER)
    		if opIndex < 0 {
    			fmt.Println("Malformed command")
    			continue
    		}
    		op := string(cmd[:opIndex])
    		//После разделителя идут данные команды в json формате
    		//Обратите внимание на то, что мы берем данные вплоть до n байт
    		//Все что дальше — мусор, и если не отрезать лишнее,
    		//мы получим ошибку декодирования json
    		data := cmd[opIndex+len(CMD_DELIMITER) : n]
    
    		//А теперь в зависимости от команды выполняем действия
    		switch op {
    		case "login":
    			var name string
    			//Декодируем сообщение и получаем логин
    			websocket.JSON.Unmarshal(data, ws.PayloadType, &name)
    			//Если такого персонажа нет онлайн
    			if _, ok := characters[name]; !ok && len(name) > 0 {
    				//Авторизуем его
    				character.Name = name
    				characters[name] = &character
    				fmt.Println(name, " logged in")
    			} else {
    				//Иначе отправляем ему ошибку
    				fmt.Println("Login failure: ", character.Name)
    				go sendError(ws, "Cannot login. Try another name")
    				continue
    			}
    		case "set-dst":
    			var p Point
    			//Игрок нажал куда-то мышкой в надежде туда переместится
    			if err := websocket.JSON.Unmarshal(data, ws.PayloadType, &p); err != nil {
    				fmt.Println("Unmarshal error: ", err)
    			}
    			//Зададим персонажу точку назначения
    			//Тогда в главном цикле, метод Character.update будет перемещать персонажа
    			character.Dst = p
    		default:
    			//Ой
    			fmt.Printf("Unknown op: %s\n", op)
    			continue
    		}
    		//И в конце оповещаем клиентов
    		//Запуск оповещения в горутине позволяет нам сразу же обрабытывать следующие сообщения
    		go notifyClients()
    	}
    }
    
    //Оповещает клиента об ошибке
    func sendError(ws *websocket.Conn, error string) {
    	//Создаем пакет, у которого заполнено только поле ошибки
    	packet := packet{Error: error}
    	//Кодируем его в json
    	msg, _, err := websocket.JSON.Marshal(packet)
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    
    	//И отправляем клиенту
    	if _, err := ws.Write(msg); err != nil {
    		fmt.Println(err)
    	}
    }
    
    //Оповещает всех подключенных клиентов
    func notifyClients() {
    	//Формируем пакет со списком всех подключенных персонажей
    	packet := packet{Characters: &characters}
    	//Кодируем его в json
    	msg, _, err := websocket.JSON.Marshal(packet)
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    
    	//И посылаем его всем подключенным клиентам
    	for _, ws := range connections {
    		if _, err := ws.Write(msg); err != nil {
    			fmt.Println(err)
    			return
    		}
    	}
    }
    
    

    Создавая персонажа мы должны задать ему какие-то параметры. В go это принято делать в функции вида NewTypename
    /* character.go */
    ...
    const (
    	CHAR_DEFAULT_SPEED = 100
    )
    ...
    func NewCharacter() Character {
    	c := Character{Speed: CHAR_DEFAULT_SPEED}
    	c.Pos = Point{100, 100}
    	c.Dst = c.Pos
    	return c
    }
    
    

    Вот и весь наш сервер.
    Статья про клиентскую часть будет написана после сбора обратной связи по этому тексту.

    Ссылки

    Демо
    Генератор карт (картинка на фоне)
    Исходники
    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 31
    • +1
      что-то я не совсем понял, почему он так странно и медленно ходит?
      • 0
        Это связано с тем, что при каждом клике, сервер оповещает всех подключенных клиентов об изменениях. Много клиентов — много сообщений от сервера, и клиент просто не успевает обрабатывать такое количество данных.

        Решить это можно, например, ограничив количество отсылаемых данных сервером: не при каждом изменении оповещать клиентов, а в фиксированные промежутки слать снапшоты мира.

        Помимо этого, лаги можно маскировать на клиенте, используя, например, интерполяцию данных.
        • +1
          Почему нет PvP? Хотя бы базового, в догонялки там поиграть…
          • +1
            Потому что это усложняет код как сервера, так и клиента, а поэтому удлиняет статью.
            Впрочем, можно добавить пвп элемент в следующем релизе и статье.
          • +21
            Я вспоминаю свои муки творчества во время написания статей на хабре и умываюсь кровавыми слезами… Можно же было просто нафигачить кусков кода с комментариями на русском языке и не париться совершенно… Вот я дурак!
            • –1
              Данный формат выбран потому, что проще всего понять как решать задачу на примере. Поэтому основная суть статьи содержится к комментариях к коду, потому что я считаю неудобным читать отдельно код, и отдельно пояснения к нему. Плюс в статье код расположен в такой последовательности, которая позволяет проще понять устройство сервера.
              • 0
                Хотелось бы тут ответить, что люди приходящие в Go — это очень круто, но формат в виде листа кода действительно не подходящий вариант, если эта статья обучающая.
                • 0
                  Мне гораздо проще разобраться в алгоритме на примере его реализации, дополненному словесным описанием.
                  При этом я не люблю воду. Мне все равно какая стояла погода в момент когда автор писал текст, и какого цвета в тот вечер был закат.

                  Какой формат был бы более приемлемым для широкой аудитории?
            • +2
              Главную особенность Go — упрощенное параллельное программирование вы исключили, думается, что причиной этому «не опытность».
              Таким образом сервер на node.js отрабатывал бы с минимальным пингом куда больше соединений, чем ваша программа на Go.
              • 0
                Пакет http сам по себе асинхронен.
                Главный цикл — в горутине.
                Отсылка ответа в отдельной горутине, что отмечено в тексте.
                Поясните, что имеется ввиду под исключением упрощенного параллельного программирования.
                • 0
                  Все же «Многопоточность», это Node.js использует «Асинхронную модель».

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

                  И когда у вас будет обрабатываться огромное количество данных, которые особо не нуждаются в мгновенной обработки, то потом вам придется увеличивать net.core.netdev_max_backlog, но это уже относится больше к тюнингу на будущее.
                  • 0
                    Для чего в данном случае могут понадобиться каналы?
                    GOMAXPROCS можно задать через переменную окружения.
                    serve создает по горутине на каждое входящее соединение, таким образом отвечая на каждое входящее соединение, по сути, асинхронно.
                    • 0
                      Каналы по своему определению связывают потерявшиеся рутины в просторах процессора, но в тоже время вы создаете некий «notifyClients()», чтобы оповестить других о том, что персонаж подключился к их семье. Каналами начинают думать, когда создается изначальная архитектура сервера, но для этого нужен некий багаж знаний — не постыжусь, но чтобы мыслить этим делом, мне понадобилось около 2 месяцев.

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

                      В глубину я не изучал работу рутин, разработчики позиционируют, как «Многопоточная модель», из этого делаю обычный вывод => все выполняется параллельно и ничего не происходит «событиями».
                      • 0
                        Возможно вы приведете пример, как в данном случае можно реализовать оповещение через каналы?

                        Проект синтетический, пишется для обучения, поэтому не вижу особой необходимости в этом.
                        Разработчики позиционируют горутины как очень легкие и дешевые треды (которые не обязательноо являются настоящими тредами). И для го-программы нормально иметь тысячи горутин.

                        Насколько я помню асинхронный с виду браузерный js на самом деле однопоточный, и вызовы просто напросто встают в очередь, создавая видимость асинхронности. Не знаю как обстоят дела в Node.js, но если я правильно понимаю, асинхронность основана на последовательном выполнении функций в очереди вызовов, что не мешает это делать в нескольких потоках.
                        • 0
                          У вас уже система завязана на массиве, который постоянно дергается, добавляя или удаляя информацию о подключенных персонажах.

                          Если говорить на словах о том, как бы работала система на каналах, то следующим образом:

                          Игрок подключается к серверу, для каждого игрока выделяется рутина с каналом, когда соединение создается или разрывается — канал уже заполнен этими данными, а они просто мониторятся в рутине каждого игрока, одновременно при изменении посылая нужный пакет.


                          Это избавляет вашу систему от бесконечного перебора в доп. рутинах, да еще и с постоянным выделением новой структуры в памяти.
                          • 0
                            По описанию не понял как это должно работать, и в чем преимущество перед обычной схемой.
                            • –1
                              Чистой воды пример, нет ничего рабочего:

                              Main loop:
                              func main() {
                              	ch := make(chan string)
                              
                              	for {
                              		go func(ch)
                              	}
                              }
                              
                              Player open connect:
                              func func(ch chan string) {
                              	text := "Hello, i am Kitty connected"
                              	ch <- text
                              }
                              
                              Player loop:
                              func func(ch chan string) {
                              	for {
                              		if ch != nil {
                              			packet.Write(<- ch)
                              		}
                              	}
                              }
                              
                              • +1
                                Получается что все клиенты получают один канал, из которого читают сообщения. Но разве после того, как первый клиент прочитает данные из канала, они оттуда не пропадут?
                                • 0
                                  Отправлять пакет и возвращать данные в канал.
              • 0
                Довольно интересно. Жду продолжение.
                • 0
                  -deleted
                  • 0
                    Cannot connect to server
                    • 0
                      Выклюл сервер, потому что, как это не печально, посетители использует возможность задания произвольного имени для разжигания межнациональной вражды и рекламы.
                      • +2
                        Добавьте динамическую генерацию имён
                        • 0
                          Добавил.
                          • 0
                            Все равно cannot. Или вы его не включали?

                            мне влом качать исходники и ставить это все на локальном компе. Дайте посмотреть что там получилось то!:)
                            • 0
                              Сейчас должно работать.
                        • 0
                          добавьте пвп, чтобы их можно было выпиливать
                      • +1
                        Вы, достаточно вольно, используете characters в двух разных горутинах NanoHandler() и mainLoop(), даже модифицируя в обеих, не только читая. Без atomic, mutex, channel. Считаете это безопасно, консистентно? Мапы в Go безопасны только для конкурентного чения из коробки. Может быть я невнимательно прочитал ваш код.
                        • 0
                          Вы не ошиблись, это может породить веселую гонку ;)
                          • 0
                            Да, вы правы — неконсистентно и небезопасно.
                            Сделано это более менее сознательно, т.к. на небольшом объеме трафика не вызывало никаких проблем.
                            Вообще говоря, всю архитектуру стоит пересмотреть в сторону использования снимков состояния мира или изменений между ними, отсылаемых в фиксированные промежутки времени при необходимости.

                            Снял плашку туториала — она тут явно лишняя, я бы даже сказал вредная.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.