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
    }
    
    

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

    Ссылки

    Демо
    Генератор карт (картинка на фоне)
    Исходники
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 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
                            Да, вы правы — неконсистентно и небезопасно.
                            Сделано это более менее сознательно, т.к. на небольшом объеме трафика не вызывало никаких проблем.
                            Вообще говоря, всю архитектуру стоит пересмотреть в сторону использования снимков состояния мира или изменений между ними, отсылаемых в фиксированные промежутки времени при необходимости.

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

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