
Каждый программист должен написать свою
Демо
Условности
Для понимая материала нужно либо знать 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/
Добавить комментарий