Каждый программист должен написать свою 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