ブラウザで動くオンライン対戦リバーシ作ってみた

ソース

ソースはきたないけど、まあ動く。エラー制御は無し。

https://github.com/kazztech/ws-reversi

デプロイしたの

複数タブで試したり出来ます。

http://tk2-404-43307.vs.sakura.ne.jp/

※chromeやsafariなどのES6対応ブラウザでお試しください。

環境

バックエンド:Node.js(Express.js)
ソケット通信:socket.io
データ管理:Redis
フロントエンド:普通のjs
描画:HTML5 Canvas

構成

ざっくりとこんな感じです。

Webサーバー

クライアントに静的ページデータの配信。

WebSocketサーバー

ゲーム中のソケット通信処理、ゲームの管理。

Redisサーバー

WebSocketサーバーでの処理で使う共通資源を格納します。

WSサーバー=ゲームサーバー

ここで注意しなければならないのは、WebSocketサーバーはリアルタイムなソケット通信を提供するためだけのものではないということです。

たとえば、押された座標をクライアントから受け取って、そのままゲームデータに反映させる仕組みだとしたらどうでしょう。

クライアントプログラムでいくら動作を制限(例えば自分のターンでしかデータを送信しないなど)していても、所詮はクライアントプログラムなので、いくらでも改変出来てしまいますし、意図しないアクションをとりかねません。

そこをうまく制御し、ゲームのバランスを保つのが、今回このWebSocketサーバーの役割でもあります。

WSサーバーとクライアントの通信例

WSサーバーとクライアントをつなぐためのプログラムはこんな感じ。クライアントから接続要求が飛んできたらサーバーのio.on(“connection”, ()=>{})が呼ばれて、処理が始まるイメージです。

// クライアント
socket = io.connect(); // 接続要求

// WSサーバー
io.on("connection", (socket) => {
    // 接続時
});

こちらはクライアントのコードです。socket.emit()で、WSサーバーでsocket.on()しているリスナーが起動し、ルームに参加します。

// クライアントからルーム参加要求
socket.emit("join-room", {
    roomName: getParam("r")
});

socket.on("join-room", (joinRoomValue) => {
    let room = joinRoomValue.roomName;
    // クライアントから受け取ったroomを元にルームに参加
    socket.join(room);
});

サーバーから特定のルームにemitする場合はto(room名).emit()とするとこで可能です。

// ルーム参加
socket.join(room);

// ルームに送信
io.to(room).emit("送信するエミット名", value);

Redis?

WSサーバーあくまでソケット通信や状態管理を取り持つもので、ユーザー共通のデータは持ちません。

そこで、NoSQLインメモリデータベース(IMDB)のredisを導入し、共有資源をおまかせすることにしました。IMDBなのでバク速です。RDBMSと比べてオーバーヘッドが1/1000になるってじっちゃんがいってました(噂)。

redisでは文字列や数値やhash型などの構造を扱えますが、ネストが出来ません。roomの配列にルームオブジェクトを追加していって、その中にデータを持ちたかったので、一旦JSONに変換して、ルーム名をkeyにして文字列型で保存することにしました。

いちいちエンコデコすると結構もたつくかなとは思ったんですが、処理時間をはかってみたら合わせても0ms未満だったのでまあ大丈夫でしょう。

リバーシアルゴリズム

正直、計算量とかは深く考えていません。とりあえず人間の思考と同じようなアルゴリズムを考えました。全体を見たい場合は冒頭で紹介したソースを見て下さい。

まず、コマのデータは二次元配列でもつことにしました。

/**
 * オセロコマ初期化
 * 何もなし:0 黒:1 白:2
 */
function initPieces() {
    return Array.apply(null, Array(8)).map((_, i) => {
        return Array.apply(null, Array(8)).map((_, j) => {
            if ((i === 3 && j === 4) || (i === 4 && j === 3)) return 1;
            if ((i === 3 && j === 3) || (i === 4 && j === 4)) return 2;
            return 0;
        });
    })
}

ゲームがスタートし、早速クライアントからアクションがあるとしましょう。受け取るのはオセロのコマの位置です。

まず最初に、redisサーバーからゲームのデータを引っ張ってきます。自分のターンであることと、押された場所になにも置かれてないことをチェックします。elseならスルーします。

let roomObject = JSON.parse(value);
if (
    roomObject.players.indexOf(socket.id) === roomObject.turn &&
    roomObject.pieces[y][x] === 0
) {
    // 次の処理
}

まず周囲を探索します。(x-1,y-1)から(x+1,y+1)をループで回し、そこに敵のコマが存在するどうかを確認します。

// その場所の周囲探索
for (let sy = -1; sy <= 1; sy++) {
    for (let sx = -1; sx <= 1; sx++) {
        // ボード外に出る、もしくは不要な位置を排除
        if (
            (sx === 0 && sy === 0) ||
            !(0 <= x + sx && x + sx <= 7) ||
            !(0 <= y + sy && y + sy <= 7)
        ) continue;
        // 周りに敵コマが存在
        if (roomObject.pieces[y + sy][x + sx] === reversePiece) {
            // 次の処理
        }
    }
}

敵コマがあれば、更に先に自分の駒があることを確認します。あれば、置ける事が確定します。探索中にボード外or何もないとスルーし、一つ上のループに戻ります。

// 次の相対位置
let xSearch = sx * 2;
let ySearch = sy * 2;
// その先に自コマがあるか探す
while (true) {
    // 範囲外排除
    if (x + xSearch < 0 || x + xSearch > 7) break;
    if (y + ySearch < 0 || y + ySearch > 7) break;
    // 進んでいる最中に空白
    if (roomObject.pieces[y + ySearch][x + xSearch] === 0) break;
    // 進んでいる最中に自コマ発見
    if (roomObject.pieces[y + ySearch][x + xSearch] === setPiece) {
        // 次の処理
    }
    xSearch += sx;
    ySearch += sy;
}

ひっくり返せることが確定したら、置いた場所とひっくり返せる場所を自分のコマに染めます。終わったら、周囲探索のループに戻り、同じ処理を繰り返します。

// ひっくり返せる場所に置いた
reversed = true;
// おいた場所に設置
roomObject.pieces[y][x] = setPiece;
// ひっくり返す数
let reverseCount = Math.max(
    Math.abs(sx - xSearch), Math.abs(sy - ySearch)
);
// ひっくり返し中の作業変数
let xStep = sx;
let yStep = sy;
// ひっくり返す
while (reverseCount > 0) {
    roomObject.pieces[y + yStep][x + xStep] = setPiece;
    xStep += sx;
    yStep += sy;
    reverseCount--;
}
break;

ひっくり返した後に置ける場所があるかをチェックします。もし一箇所もおけない盤面になったら、ターンを切り替えず、パス処理を行います。ここもだいたい似たような処理です。うまくまとめれたらよかったんですが。

if (reversed) {
    check: for (let ly = 0; ly < 8; ly++) {
        for (let lx = 0; lx < 8; lx++) {
            if (roomObject.pieces[ly][lx] !== 0) continue;
            for (let sy = -1; sy <= 1; sy++) {
                for (let sx = -1; sx <= 1; sx++) {
                    // ボード外に出る探索は中止
                    if (
                        (sx === 0 && sy === 0) ||
                        !(0 <= lx + sx && lx + sx <= 7) ||
                        !(0 <= ly + sy && ly + sy <= 7)
                    ) continue;
                    // 周りに敵コマが存在
                    if (roomObject.pieces[ly + sy][lx + sx] === setPiece) {
                        // 次の相対位置
                        let xSearch = sx * 2;
                        let ySearch = sy * 2;
                        // その先に自コマがあるか探す
                        while (true) {
                            // 範囲外
                            if (lx + xSearch < 0 || lx + xSearch > 7) break;
                            if (ly + ySearch < 0 || ly + ySearch > 7) break;
                            // 進んでいる最中に空白
                            if (roomObject.pieces[ly + ySearch][lx + xSearch] === 0) {
                                break;
                            }
                            // 進んでいる最中に自コマ発見
                            if (roomObject.pieces[ly + ySearch][lx + xSearch] === reversePiece) {
                                pass = false;
                                break check;
                            }
                            xSearch += sx;
                            ySearch += sy;
                        }
                    }
                }
            }
        }
    }
}
// パスならターンを切り替えない
if (pass) nextTurn = roomObject.turn;
// ターン切替
roomObject.turn = nextTurn;
// クライアントに盤面送信
io.to(room).emit("update-pieces", {
    pieces: roomObject.pieces,
    nextTurn: nextTurn
});
// redisに保存
redisClient.set(room, JSON.stringify(roomObject));

VPSで公開

とりあえずさくらのVPSの一番安いプランを借りて、SSHでコード転送して環境構築しておわり。

The present writer kazz.

関連記事

+ブラウザで遊べるリバーシを作る
+LINEBOTを楽に実装できるPHPライブラリ作った
+PHPで簡易LINEBotを作ってみた
+webスクレイピングで英単語の和訳を取得する
+「これ1冊でできる!ラズベリーパイ超入門」を読んで