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 }  

Вот и весь наш сервер.


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


Ссылки

Демо
Генератор карт (картинка на фоне)
Исходники

ссылка на оригинал статьи http://habrahabr.ru/post/212701/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *