Робимо багато користувачів ігрy на Go і WebSocket'ах

  golang gopher
Продовжуємо знайомство з мовою програмування Go (golang). Минулого разу ми подивилися основні конструкції мови. У цій статті я хочу показати використання горутін і каналів. І, звичайно, продемонструвати все це на реальному додатку, в данноcм випадку багатокористувацької грі. Розглядати будемо не всю гру, а тільки ту частину бекенда, яка відповідає за мережеве взаємодія між гравцями за допомогою WebSoket.
 
Гра покрокова, для двох гравців. Однак, описані нижче прийоми можна застосовувати для створення інших ігор, від покеру до стратегій.
 
До речі, це моя перша гра і перша робота з WebSoket'амі, так що не судіть строго. Якщо у вас є зауваження і обгрунтована критика, із задоволенням вислухаю.
 
Алгоритм наступний. Гравці підключаються до ігровій кімнаті (room). При надходженні нового ходу від гравця, кімната сповіщається про це (через канал) і викликав спеціальний метод «оновити ігрове стан» на всіх гравцях, зареєстрованих в кімнаті. Все досить просто.
 
Схематично це можна зобразити так:
 
 
 
Спілкування з гравцем відбувається через об'єкт-прошарок «з'єднання» (на рис. 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 містить булево значення і служить тільки для сповіщення чи потрібно оновлювати стан гри. Нам не важливо що в нього передається ми лише реагуємо на його спрацьовування. А ось в канал 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, це весело.
 
 
 
 Посилання:
   
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.