Goでゲームのロビー機能のようなモノを作る

## はじめに

こちらは [Go2 Advent Calendar 2020](https://qiita.com/advent-calendar/2020/go2) 9日目の記事です。

ゲームのマッチングを行うロビー機能のようなモノをGoで作ってみたので、紹介と簡単な解説をしたいと思います。
ログインしたらロビーでマッチングするまで待機し、相手が見つかったらゲーム画面に接続するというモノです。今回作った内容に、認証機能は含まれていません。

## 作ったもの

connect4という１対１のゲームをベースにしています。
プレイヤー名を入力してログインすると、ロビー（マッチング画面）に移動します。
プレイヤーが２名揃うとプレイ画面が表示されて、ゲームが始まります。

![connect4play.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271949/64105f5c-f235-67f1-780d-523b8a47034c.gif)

## 仕組み

次の３つのページで構成しています。
- ログインページ：プレイヤー名を入力してログインするためのページ
- ロビーページ：対戦相手のマッチング待つ待機用のページ
- ゲーム画面

ログインではクライアント-サーバ間でセッションを作ります。
サーバにプレイヤー名を登録して、クライアントにセッションIDを発行しています。
同時にWebSocketのペアを用意してプレイヤーに割り当てます。

ロビーでは、他のプレイヤーが同じWebSocketのペアに割り当てられるのを待機します。
割り当てが終わるとゲーム画面に移動します。

ゲーム画面では割り当てられたWebSocketのペアで通信してゲームを進めます。

![gameroom.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/271949/ee0177cc-836b-f59e-e2d3-5c544f7c2c35.png)


```go
func main() {
	flag.Parse()
	http.HandleFunc("/", serveFront)  // ログインページ
	http.HandleFunc("/lobby", serveLobby) // ロビーページ
	http.HandleFunc("/play", servePlay) // ゲーム画面
	http.HandleFunc("/login", serveLoginHandler) // Session開始とWebSocketペアの発行
	http.HandleFunc("/ws", serveWebsocket) // WebSocket処理
	err := http.ListenAndServe(*addr, nil)
	if err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}
```

### セッション管理

セッションの構造体定義は以下の通りです。
Cookieを使ってsessionIDでやり取りします。`Session`の更新はManagerを介して行います。

```go
type Session struct {
	cookieName string
	ID         string
	manager    *Manager
	Values     map[string]interface{}
	writer     http.ResponseWriter
}
```

#### セッションを開始する
セッション管理はManagerで行っていて、`manager.Start()`でセッションを開始します。この中ではcookieNameと`*http.Request`のcookie情報を基にセッションを取得しています。このとき、既にセッションが確立していれば、既存のセッションを返します。
セッション情報がない場合は、ログインに使ったプレイヤー名をセッションに登録して保存します。このとき`http.responseWriter`にcookie情報をセットして返します。

これでクライアントにセッションIDを発行できました。

```go
// セッションを開始
manager := sessions.NewManager()
session, err := manager.Start(w, r, cookieName)
if err != nil {
	http.Error(w, "session start faild", http.StatusMethodNotAllowed)
	return
}
session.Set("account", r.FormValue("account"))
if err := session.Save(); err != nil {
	http.Error(w, "session save faild", http.StatusMethodNotAllowed)
	return
}
```

### WebSocket通信

WebSocket通信は`gorilla/websocket`を使っています。
単一のWebSocket通信はexamplesを参考にしているので、そちらを見てください。
`hub`に接続したクライアント同士がWebSocketのペアとなり、メッセージを受け取れるようになっています。

単一のWebSocketペアは`hub`で管理されていますが、複数のPlayルームを用意したいので複数のWebSocketペアをManagerを作って管理します。Managerの構造体は次のようになっています。

```go
type Manager struct {
	database map[string]*Hub
	pool     []*Hub
	count    map[*Hub]int
	users    map[*Hub][]string
}
```

サーバは複数の`hub`を`pool`に持っていて、割り当てを要求されたら`pool`の先頭から順に割り当てるという単純な作りです。`hub`の定員に達したら`pool`から除外していきます。今回は１対１のゲームなので、`hub`の定員は2名です。

```go
func (m *Manager) Get(key string) (*Hub, error) {
	if hub, ok := m.database[key]; ok {
		return hub, nil
	}
	if len(m.pool) <= 0 {
		return nil, fmt.Errorf("no hub")
	}

	hub := m.pool[0] // 先頭のHubを割り当てる
	m.database[key] = hub
	m.count[hub]++
	if m.count[hub] >= PoolMax {
		m.pool = m.pool[1:] // 先頭のHubを除外する
	}
	m.users[hub] = append(m.users[hub], key)
	return hub, nil
}
```

サーバは`hub`が定員に達したら、マッチング完了を待機中のクライアントに対してWebSocketで通知し、クライアントは通知を受け取るとゲーム画面に移動します。

## 今後について
ログイン時に入力したユーザ名が活用できていないのと、使い終わった`hub`を`pool`に戻す処理、終了処理が未対応なので、これらは近いうち作ろうと思います。