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

в 7:06, , рубрики: canvas, game development, html5, javascript, MMO, mmorpg, WebSocket, метки: , , , , ,

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) disconnectedn", 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): %sn", 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: %sn", 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js