Программист
0,0
рейтинг
1 апреля 2014 в 11:06

Разработка → 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
}


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

Ссылки

Демо
Генератор карт (картинка на фоне)
Исходники
@TatriX
карма
30,0
рейтинг 0,0
Программист
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

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

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