ブラウザで遊べるリバーシを作る

完成品:Reversi!
ソース:source:Reversi!

この「Reversi!」は、フラッシュやWebGLなどの外部技術を使わず、標準的なHTML5とJavaScriptで構成されています。描画はHTML5のCanvasを使ってます。

canvasのレスポンシブ

canvasの描画は、canvas内の座標(px)をもとに要素の配置を行うため、cssのメディアクエリーやmax-widthなどを使ってサイズを変えたところで、中身がオーバーフローしてしまいます。

そこで考えたのが、「cssのtransform:scaleを使って拡大縮小する」「js側でブラウザのウィンドウサイズを取得し、それを基準に係数を求め、要素の座標すべてにその係数をかける」の二つです。

僕は後者で実装しました。いちいち面倒ですが、先にそっちで成功したので。

let ua = navigator.userAgent; // UA取得
// ユーザーエージェントで分岐
if(ua.indexOf("iPhone") > 0 || ua.indexOf("iPod") > 0 ||
   ua.indexOf('Android') > 0 && ua.indexOf('Mobile') > 0){
    // スマホの場合
    // 横幅をウィンドウの幅に
    cvs.width = window.innerWidth;
    // 縦幅はウィンドウの幅の1.4倍に
    cvs.height = window.innerWidth * 1.4;
    // 係数
    c = window.innerWidth / 400;
} else {
    let body = document.body;
    // タブレット、PCの場合
    // 画面中央に揃えるスタイルを追加
    body.style.top = "50%";
    body.style.left = "50%";
    body.style.transform = "translate(-50%,-50%)";
    // 横400px縦560px固定
    cvs.width = 400;
    cvs.height = 560;
    // 係数は1(標準)
    c = 1;
}

ctx要素を追加するときは、サイズや座標に係数をかけます。

ctx.font = 36*c + "px Comfortaa";
ctx.fillStyle = "rgba(255, 255, 255, 1)";
ctx.fillText("PLAY!", 150*c, 418*c);

オセロのアルゴリズム

オセロのアルゴリズムといっても色々パターンがありそうですが、あえて自分で考えてみました。

周囲8マスに相手の駒があり、その延長線上に自分の駒があったらひっくり返す。このルールを意識し、総当たり方式で実装しました。

抜粋なので分かりにくいかも。(0<=x<=7)

// main.js抜粋
let map = [ // 9:壁,1:自分,2:相手
    [9,9,9,9,9,9,9,9,9,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,0,0,0,2,1,0,0,0,9],
    [9,0,0,0,1,2,0,0,0,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,0,0,0,0,0,0,0,0,9],
    [9,9,9,9,9,9,9,9,9,9]
];
// 周囲8マスを調べる
for (let vx = -1; vx <= 1; vx++) {
    for (let vy = -1; vy <= 1; vy++) {
        // 周囲のマスに相手のコマがあるか
        if (x + vy + 1][x + vx + 1] == 2) {
            // その先
            var mx = vx * 2;
            var my = vy * 2;
            // そもそもおける場所か
            while (1) {
                if (map[x + my + 1][x + mx + 1] == 2) {
                    mx += vx;
                    my += vy;
                    continue;
                }
                // その延長線上に自コマがあるか
                if (map[y + my + 1][x + mx + 1] == 1) {
                    var rx = vx;
                    var ry = vy;
                    // 裏返す
                    while (1) {
                        if (map[y + ry + 1][x + rx + 1] == 2) {
                            map[y + ry + 1][x + rx + 1] = 1;
                            rx += vx;
                            ry += vy;
                            continue;
                        }
                        // そのマスも裏返す
                        map[y + 1][y + 1] = 1;
                        // コマ数計算
                        pieceCount();
                        turn = "cpu";
                        break;
                    }
                }
                break;
            }
        }
    }
}

CPUの打ち手

静的な評価マップを用意しておいて、都度参照し使いました。雑な評価方法だと思ったのですが、そこそこの強さを発揮してくれました。

const eval_map_1 = [ // 評価マップ1
    [1,1,1,1,1,1,1,1],
    [1,2,2,2,2,2,2,1],
    [1,2,3,3,3,3,2,1],
    [1,2,3,0,0,3,2,1],
    [1,2,3,0,0,3,2,1],
    [1,2,3,3,3,3,2,1],
    [1,2,2,2,2,2,2,1],
    [1,1,1,1,1,1,1,1]
];
const eval_map_2 = [ // 評価マップ2
    [8,2,7,5,5,7,2,8],
    [2,1,3,3,3,3,1,2],
    [7,3,6,4,4,6,3,7],
    [5,3,4,0,0,4,3,5],
    [5,3,4,0,0,4,3,5],
    [7,3,6,4,4,6,3,7],
    [2,1,3,3,3,3,1,2],
    [8,2,7,5,5,7,2,8]
];

この二つの評価を使っています。
序盤:中央を優先的に、中盤:場所取りを優先、終盤:場所ととれるコマ数をもとに。

// main.js抜粋
// 可動域マップ初期化
mov_scope = [
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0]
];
// 全マス総当たり
for(let y = 1; y <= 8; y++) {
    for(let x = 1; x <= 8; x++) {
        if(map[y][x] == 0) {
            // 周囲8マスを調べる
            for (let vx = -1; vx <= 1; vx++) {
                for (let vy = -1; vy <= 1; vy++) {
                    // 隣にplayerコマがあるか
                    if (map[y + vy][x + vx] == 1) {
                        var rev_cnt = 1; // 裏返しカウント
                        var mx = vx * 2;
                        var my = vy * 2;
                        // 進む
                        while (1) {
                            if (map[y + my][x + mx] == 1) {
                                mx += vx;
                                my += vy;
                                rev_cnt++;
                                continue;
                            }
                            // さらにその先にCUPコマがあれば
                            // 置けることが確定
                            if (map[y + my][x + mx] == 2) {
                                if(piece.count.white + piece.count.black < 20) {
                                    mov_scope[y - 1][x - 1] = eval_map_1[y - 1][x - 1];
                                } else if(piece.count.white + piece.count.black < 50) {
                                    mov_scope[y - 1][x - 1] = eval_map_2[y - 1][x - 1];
                                } else {
                                    mov_scope[y - 1][x - 1] = eval_map_2[y - 1][x - 1] + rev_cnt;
                                }
                            }
                            break;
                        }
                    }
                }
            }
        }
    }
}
// 評価が高い順に置ける場所を厳選
let maxFlg = false;
let maxArr = [];
for(let m = 14; m >= 2; m--) {
    for(let i = 0; i <= 7; i++) {
        for(let j = 0; j <= 7; j++) {
            if(mov_scope[j][i] == m) {
                maxArr.push([i,j]);
                maxFlg = true;
            }
        }
    }
    // その評価で置けるところがあったらbreak
    if(maxFlg) {
        break;
    }
}
// どこも置けなかったらターンチェンジ
if(maxArr.length == 0) {
    turn = "player";
    return;
}
// 裏返す
let rdm = Math.floor(Math.random() * maxArr.length); // maxArrからランダムで一か所取得
// 周囲8コマ(+1はmapと添え字の差分)
for (let vx = -1; vx <= 1; vx++) {
    for (let vy = -1; vy <= 1; vy++) {
        if (map[maxArr[rdm][1] + 1 + vy][maxArr[rdm][0] + 1 + vx] == 1) {
            var mx = vx * 2;
            var my = vy * 2;
            while (1) {
                if (map[maxArr[rdm][1] + 1 + my][maxArr[rdm][0] + 1 + mx] == 1) {
                    mx += vx;
                    my += vy;
                    continue;
                }
                if (map[maxArr[rdm][1] + 1 + my][maxArr[rdm][0] + 1 + mx] == 2) {
                    var rx = vx;
                    var ry = vy;
                    while (1) {
                        if (map[maxArr[rdm][1] + 1 + ry][maxArr[rdm][0] + 1 + rx] == 1) {
                            map[maxArr[rdm][1] + 1 + ry][maxArr[rdm][0] + 1 + rx] = 2;
                            rx += vx;
                            ry += vy;
                            continue;
                        }
                        // 置いた場所にCPUコマ
                        map[maxArr[rdm][1] + 1][maxArr[rdm][0] + 1] = 2;
                        // コマカウント
                        pieceCount();
                        turn = "player";
                        break
                    }
                }
                break;
            }
        }
    }
}

まとめ

パフォーマンス的に改善できる部分が多々ありそうですが、とりあえずこれで完成。

WebSocketでオンライン対戦とか、機械学習でモデルを作ってとか、やれたらいいですねぇ…。

The present writer kazz.

関連記事

+LINEBOTで画像文字起こしと有害検出
+‪ラズパイと温度センサ(LM75B)で室温の推移をグラフ化する‬
+Markdownで講義ノートを書くためのノート
+webスクレイピングで英単語の和訳を取得する
+PHPで簡易LINEBotを作ってみた