Делаем многопользовательскую игрy на Go и WebSocket’ах

в 5:21, , рубрики: game development, Gamedev, golang, WebSocket, Программирование, метки: , ,

golang gopher
Продолжаем знакомство с языком программирования Go (golang). В прошлый раз мы посмотрели основные конструкции языка. В этой статье я хочу показать использование горутин и каналов. И, конечно, продемонстрировать все это на реальном приложении, в данноcм случае многопользовательской игре. Рассматривать будем не всю игру, а только ту часть бэкэнда, которая отвечает за сетевое взаимодействие между игроками посредством WebSoket.

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

Кстати, это моя первая игра и первая работа с WebSoket'ами, так что не судите строго. Если у вас есть замечания и обоснованная критика, с удовольствием выслушаю.

Алгоритм следующий. Игроки подключаются к игровой комнате (room). При поступлении нового хода от игрока, комната извещается об этом (через канал) и вызвает специальный метод «обновить игровое состояние» на всех игроках, зарегистрированных в комнате. Все довольно просто.

Схематично это можно изобразить так:

Делаем многопользовательскую игрy на Go и WebSocketах

Общение с игроком происходит через объект-прослойку «соединение» (на рис. pConn1, pConn2), который расширяет тип Player (встраивая его в себя) и добавляет методы для коммуникации.

Кстати, я буду иногда употреблять слово «объект» как обозначение некоторой сущности, а не в смысле ООП объекта (т.к. в go они немного отличаются).

Рассмотрим структуру проекта:

/wsgame/
  /game/
    game.go
  /templates/
  /utils/
    utils.go
  main.go
  conn.go
  room.go

В пакете /game/ лежит сам движок игры. Его мы рассматривать не будем, тут я приведу лишь несколько методов, в виде mock'ов, которые нужны для управления игрой.

В корневых файлах (пакет main) реализованно наше сетевое взаимодействие.

Игра

/game/game.go

package game

import (
	"log"
)

type Player struct {
	Name  string
	Enemy *Player
}

func NewPlayer(name string) *Player {
	player := &Player{Name: name}
	return player
}

func PairPlayers(p1 *Player, p2 *Player) {
	p1.Enemy, p2.Enemy = p2, p1
}

func (p *Player) Command(command string) {

	log.Print("Command: '", command, "' received by player: ", p.Name)
}

func (p *Player) GetState() string {
	return "Game state for Player: " + p.Name
}

func (p *Player) GiveUp() {
	log.Print("Player gave up: ", p.Name)
}

У игрока (Player) есть враг, такой же игрок (в нашей структуре это указатель *Player). Для соединения игроков служит функция PairPlayers. Далее, тут представлены некоторые функции, нужные для управления игрой. Здесь они ничего не делают, только выводят сообщение в консоль. Command — послать команду (сделать ход); GetState — получить текущее состояние игры для данного игрока; GiveUp — сдаться и присвоить победу противнику.

Main

main.go

package main

import (
	"github.com/alehano/wsgame/game"
	"github.com/gorilla/websocket"
	"html/template"
	"log"
	"net/http"
	"net/url"
)

const (
	ADDR string = ":8080"
)

func homeHandler(c http.ResponseWriter, r *http.Request) {
	var homeTempl = template.Must(template.ParseFiles("templates/home.html"))
	data := struct {
		Host       string
		RoomsCount int
	}{r.Host, roomsCount}
	homeTempl.Execute(c, data)
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
	ws, err := websocket.Upgrade(w, r, nil, 1024, 1024)
	if _, ok := err.(websocket.HandshakeError); ok {
		http.Error(w, "Not a websocket handshake", 400)
		return
	} else if err != nil {
		return
	}

	playerName := "Player"
	params, _ := url.ParseQuery(r.URL.RawQuery)
	if len(params["name"]) > 0 {
		playerName = params["name"][0]
	}

	// Get or create a room
	var room *room
	if len(freeRooms) > 0 {
		for _, r := range freeRooms {
			room = r
			break
		}
	} else {
		room = NewRoom("")
	}

	// Create Player and Conn
	player := game.NewPlayer(playerName)
	pConn := NewPlayerConn(ws, player, room)
	// Join Player to room
	room.join <- pConn

	log.Printf("Player: %s has joined to room: %s", pConn.Name, room.name)
}

func main() {
	http.HandleFunc("/", homeHandler)
	http.HandleFunc("/ws", wsHandler)

	http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, r.URL.Path[1:])
	})

	if err := http.ListenAndServe(ADDR, nil); err != nil {
		log.Fatal("ListenAndServe:", err)
	}
}

Этот входная точка в программу. Функция main() запускает сервер и регистрирует два обработчика: homeHandler для главной страницы, который лишь выводит шаблон home.html и более интересный wsHandler, который устанавливает WebSocket соединение и регистрирует игрока.

Для WebSocket мы используем пакет из набора Gorilla Toolkit («github.com/gorilla/websocket»). В начале мы создаем новое соединение (ws). Далее, получаем имя игрока из параметра URL. Затем, ищем свободную комнату (с одним игроком). Если комнаты нет, то создаем ее. После этого, создаем игрока и объект соединения для игрока (pConn). Передаем в соединение наш вебсокет, игрока и комнату. Точнее, передаем указатели на эти объекты. И последним шагом подключаем наше соединение к комнате. Делается это посылкой нашего объекта в канал join комнаты.

Горутины и каналы

Небольшой ликбез про горутины и каналы. Горутины — это что-то вроде потоков, они выполняются параллельно. Достаточно поставить оператор go перед вызовом функции и программа не будет ждать пока функция завершится, а сразу перейдет к следующей инструкции. Горутины очень легковесны, не требовательны к памяти. Общение с горутинами происходит через каналы — специальный тип данных. Каналы похожи на pipe в Unix. Можно представлять каналы как трубу: в один конец мы кладем что-то, из другого получаем. Тип канала может быть любой. Например, можно создать канал string и передавать в него сообщения. Возможно даже создать канал каналов. We need to go deeper.

Небольшой пример. Запустить можно тут http://play.golang.org/p/QUc458nBJY
Представьте, что вы хотите отправить одинаковый запрос на несколько серверов и получить ответ от того, кто быстрее ответит. И не хотите ждать остальных. Сделат это можно так:

package main

import "fmt"

func getDataFromServer(resultCh chan string, serverName string) {
	resultCh <- "Data from server: " + serverName
}

func main() {
	res := make(chan string)
	go getDataFromServer(res, "Server1")
	go getDataFromServer(res, "Server2")
	go getDataFromServer(res, "Server3")

	data := <- res
	fmt.Println(data)
}

Мы создаем канал res, куда будем получать ответ. А затем, в отдельных горутинах, запускаем запросы к серверам. Операция не блокирующая, поэтому после строки с оператором go программа переходит на следующую строку. Далле, программа блокируется на строке data := <- res, ожидая ответа из канала. Как только ответ будет получен, мы выводим его на экран и программа завершается. В данном синтетическом примере будет возвращаться ответ от Server1. Но в жизни, когда выполнение запроса может занимать разное время, будет возвращен ответ от самого быстрого сервера.

Итак, вернемся к нашим баранам.

Соединение

conn.go

package main

import (
	"github.com/alehano/wsgame/game"
	"github.com/gorilla/websocket"
)

type playerConn struct {
	ws *websocket.Conn
	*game.Player
	room *room
}

// Receive msg from ws in goroutine
func (pc *playerConn) receiver() {
	for {
		_, command, err := pc.ws.ReadMessage()
		if err != nil {
			break
		}
		// execute a command
		pc.Command(string(command))
		// update all conn
		pc.room.updateAll <- true
	}
	pc.room.leave <- pc
	pc.ws.Close()
}

func (pc *playerConn) sendState() {
	go func() {
		msg := pc.GetState()
		err := pc.ws.WriteMessage(websocket.TextMessage, []byte(msg))
		if err != nil {
			pc.room.leave <- pc
			pc.ws.Close()
		}
	}()
}

func NewPlayerConn(ws *websocket.Conn, player *game.Player, room *room) *playerConn {
	pc := &playerConn{ws, player, room}
	go pc.receiver()
	return pc
}

Что же представляет собой прослойка-соединение? Это объект playerConn, который содержит указатели: на вебсокет, на игрока и на комнату. В случае игрока, мы написали просто *game.Player. Это значит, что мы «встраиваем» Player и можем вызывать его методы прямо на playerConn. Что-то вроде наследования. При создании нового соединения (NewPlayerConn) запускается метод receiver в отдельной горутине (оператор go), т.е. параллельно (не блокирующим образом) и в бесконечном цикле слушает вебсокет на предмет сообщений. При получении оного, оно передается игроку в метод Command (сделать ход). А потом отправляет в комнату сигнал «обновить состояние игры для всех игроков». При возникновении ошибки (например разрыве вебсокета), горутина выходит из цикла, посылает в канал комнаты сигнал «сдаться», закрывает вебсокет и завершается.
Методом sendState() мы посылаем текущее состояние игры данному игроку.

Комната

room.go

package main

import (
	"github.com/alehano/wsgame/game"
	"github.com/alehano/wsgame/utils"
	"log"
)

var allRooms = make(map[string]*room)
var freeRooms = make(map[string]*room)
var roomsCount int

type room struct {
	name string

	// Registered connections.
	playerConns map[*playerConn]bool

	// Update state for all conn.
	updateAll chan bool

	// Register requests from the connections.
	join chan *playerConn

	// Unregister requests from connections.
	leave chan *playerConn
}

// Run the room in goroutine
func (r *room) run() {
	for {
		select {
		case c := <-r.join:
			r.playerConns[c] = true
			r.updateAllPlayers()

			// if room is full - delete from freeRooms
			if len(r.playerConns) == 2 {
				delete(freeRooms, r.name)
				// pair players
				var p []*game.Player
				for k, _ := range r.playerConns {
					p = append(p, k.Player)
				}
				game.PairPlayers(p[0], p[1])
			}

		case c := <-r.leave:
			c.GiveUp()
			r.updateAllPlayers()
			delete(r.playerConns, c)
			if len(r.playerConns) == 0 {
				goto Exit
			}
		case <-r.updateAll:
			r.updateAllPlayers()
		}
	}

Exit:

	// delete room
	delete(allRooms, r.name)
	delete(freeRooms, r.name)
	roomsCount -= 1
	log.Print("Room closed:", r.name)
}

func (r *room) updateAllPlayers() {
	for c := range r.playerConns {
		c.sendState()
	}
}

func NewRoom(name string) *room {
	if name == "" {
		name = utils.RandString(16)
	}

	room := &room{
		name:        name,
		playerConns: make(map[*playerConn]bool),
		updateAll:   make(chan bool),
		join:        make(chan *playerConn),
		leave:       make(chan *playerConn),
	}

	allRooms[name] = room
	freeRooms[name] = room

	// run room
	go room.run()

	roomsCount += 1

	return room
}

Последняя часть — комната. Мы создаем несколько глобальных переменных: allRooms — список всех созданных комнат, freeRooms — комнаты с одним игроком (по идее, не должно быть больше одной), roomsCount — счетчик работающих комнат.

Объект room содержит имя комнаты, playerConns — список подключенных соединений (игроков) и несколько каналов для управления. Каналы могут иметь разный тип, это то, что можно отправить в канал и принять из него. Например, кнал updateAll содержит булево значение и служит только для извещения нужно ли обновлять состояние игры. Нам не важно что в него передается мы лишь реагируем на его срабатывание. Правда, хорошей практикой считается в таком случае использовать пустую структуру struct{}. А вот в канал join передается конкретное соединение (точнее указатель на него). Его мы сохраняем в нашей комнате в playerConns в качестве ключа структуры map.

При создании новой комнаты посредством NewRoom(), мы инициализируем каналы и запускаем метод run() в горутине (go room.run()). Он выполняет бесконечный цикл, который слушает одновременно несколько каналов и, при получении сообщения в любом из них, выполняет определенные действия. Прослушивание нескольких кналов реализуется с помощью конструкции select-case. В данном случае операция блокирующая. Т.е. мы будем ждать пока из какого-либо канала не придет сообщение, затем перейдем на следующую итерацию цикла и опять будем ждать. Но, если бы в конструкции select была бы секция default:, тогда операция была бы не блокирующей и при отсутствии сообщений выполнялся бы блок default, а затем выход из select. В данном случае это бессмысленно, но возможность такая есть.

Если срабатывает канал join, мы регистрируем данное соединение (игрока) в комнате. Если подключается второй игрок, мы «спариваем» игроков и удаляем комнату из списка свободных. При срабатывании leave, удаляем соединение, и выполняем метод «сдаться» у игрока. А если в комнате не осталось игроков len(r.playerConns) == 0, то вообще закрываем комнату, выйдя из цикла (goto Exit). Да, в языке go есть инструкция goto. Но не пугайтесь, она используется крайне редко, и только для того чтобы выйти из структур типа for или select. Например, для выхода из вложенного цикла. В данном случае, если поставить break, он прервет конструкцию select, а не цикл for.

Ну и, наконец, при срабатывании канала updateAll (передаваемое значение нам не важно, поэтому мы его никуда не сохраняем: case <-r.updateAll), у всех зарегистрированных в комнате игроков вызывается метод «обновить состояние игры».

Вот и вся сетевая часть. В реальном проекте она стала немного сложнее. Добавились каналы отвечающие за чат и таймер, а так же, добавилась некая структура запроса-ответа (на оснве JSON).

Имея такой бэкэнд, довольно просто сделать клиенты на разных устройствах. Я решил сделать клиент на HTML5 для кроссплатформенности. Хотя в iOS игра постоянно вылетает. Видно, поддержка websocket реализована не полностью.

Спасибо за внимание. Программируйте на Go, это весело.

Ссылки:

Автор: alehano

Источник

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


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