CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

Blenderとthree.jsで3Dオセロゲームを作ってみた

はじめまして、こんにちは 。 技術開発ユニットの岸部です。

先日、研究所でオセロAI大会を開催し、その模様を萩原さんが記事にまとめて公開してくれました! techblog.cccmk.co.jp

この記事の中でも触れてくれていますが、オセロAIの対局用の3Dオセロゲームを Blenderとthree.jsを使って作ってみたので、その内容を簡単に紹介したいと思います。

今回作ったものはこちらになります。

私の拙いコード全文はgithubで公開していますので、コードが気になる方はこちらを参照ください。

なぜ3Dオセロゲームを作ったのか

私もオセロAI大会の参加者としてエントリーしていましたが、とても優勝できると思えず、他のところで存在感を発揮したかったからです。
オセロAI同士をどこで戦わせるかが課題でした。
jupyter notebookでやるかコマンドラインでやるか色々と検討していたのですが、Zoomでのオンライン開催とオーディエンスとして社長と所長の参加が決まりました。
目に見えたほうが盛り上がるだろうと思い、前々から3Dモデリングをやってみたいと思っていて、ちょうどいい機会だと思いチャレンジしてみました。

どうやって3Dオセロゲームを作るか

3Dモデリングには、以前三浦さんが使っていたオープンソースのBlenderを使うことは決めていました。

techblog.cccmk.co.jp

問題はゲーム部分で、3DゲームといえばUnityというイメージで、軽く調べてみましたが、結構ハードルが高そうで断念しました。 手っ取り早くゲームを作るならjavascriptだと思い、調べてみると3Dモデルを扱えるthree.jsというライブラリがあることを知り、javascrptを採用することにしました。
こちらの記事を参考に作っていきました。

Blenderでオセロの駒と盤面を作る

今回使用したBlenderのバージョンは2.8.2です。
3Dモデルデータのエクスポートの形式をglTF 2.0に指定する必要があるため、最低でもBlenderのバージョンは2.8以降である必要があります。

オセロの駒は簡単で、円柱を平べったくして、2つ重ねるだけです。
黒と白で塗ってしまえば完成です。
大きさはjavascript側で制御するので適当でよいです。

f:id:kt-watson:20200619120308p:plain
円柱を2つ重ねて黒と白で塗る

盤面も簡単で、平べったい大きい黒い立方体の上に、やや小さい緑の立方体を重ね、黒の縦長の立方体で線を表現するだけです。
多少の歪みは愛嬌です。

f:id:kt-watson:20200622095659p:plain
立方体を組み合わせて盤面を作る

作成した3Dモデルは、glTF 2.0形式でエクスポートしましょう。
「ファイル」→「エクスポート」→「glTF 2.0」

three.jsで3Dモデルを読み込む

出力したglTF 2.0形式の3Dモデルをthree.jsで読み込みます。 全部書くと長くなってしまうので、3Dモデル読込部分だけ抜き出すと、 three.jsのGLTFLoaderというライブラリを使って、

const loader = new THREE.GLTFLoader();
let model = null;
loader.load(
        '3Dモデルファイルのパス',
        function (gltf) {
            model = gltf.scene;
            // 3Dモデルのサイズ
            model.scale.set(50.0, 50.0, 50.0);
            // 3Dモデルの配置場所
            model.position.set(0, 0, 0);
            // three.jsのsceneに追加
            scene.add(model);
            console.log(model);
        },
        function (error) {
            console.log(error);
        }
    );

こんな風に書きます。
scaleとpositionの数値を変えて、調整します。
複数の3Dオブジェクトを読込たい場合は、上記コードを繰り返します。

three.jsと3Dモデルでオセロを表現する

だいたい準備が整ったので、いよいよオセロを表現していきます。
基本的にはBlenderで作った3Dモデルのファイルを読み込むだけですが、
いくつか注意点・工夫点があるので紹介します。

オセロの盤面の状況をjavascriptでどう表現するか

オセロの8×8の盤面の状況を、javascriptの二次元配列で表現します。

let board = [
    [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,-1,1,0,0,0],
    [0,0,0,1,-1,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=何も置いてない
1=黒
-1=白
です。
このように表現することで、-1を掛ければ黒と白をひっくり返す処理が表現できるので、便利です。
こちらは初期配置を表現しています。

オセロ盤を配置する

GLTFLoaderを使って、オセロ盤の3Dモデルを読み込むだけです。
scaleとpositionの値は、実際に画面に表示させて、いい感じに調整します。

駒を配置する

駒の配置は重要です。
64個の駒を自作のオセロ盤の枠内にきちんと配置しないといけません。
いい配置の仕方が思いつかなかったので、地道にscaleとpositionを調整しました・・・(その成果がboardMapです)
また、駒は64個の3Dオブジェクトで1セットと考え、group化しました。
盤面の状況を表す二次元配列をloop処理し、1つずつ駒をセットしていきます。

// 駒の配置情報
const boardMap = {
    x:[-440,-315,-190,-65,60,185,310,435],
    z:[-430,-305,-180,-55,70,195,320,445],
}

// 駒グループ作成
const komagroup = new THREE.Group();
komagroup.name = "komagroup";

// sceneに追加
scene.add(komagroup);

// 駒の配置
for(let row = 0; row < 8; row++){
    for(let col = 0; col < 8; col++){
        loader.load(
            "駒3Dモデルのパス",
            function (gltf) {
                // 座標に応じて駒の名前をつける( (3,4)ならばkoma34 )
                gltf.scene.name = "koma" + row + col;
                gltf.scene.scale.set(19.0, 19.0, 19.0);
                gltf.scene.position.set(boardMap.x[col], 40,boardMap.z[row]);
                // その座標の値が0(何も置いていない)なら、駒3Dモデルを見えなくする
                if(board[row][col] === 0){
                    gltf.scene.visible = false;
                }
                // その座標の値が-1(白が置いてある)なら、駒3Dモデルを1回転する
                if(board[row][col] === -1){
                    gltf.scene.rotation.set(0, 0, Math.PI);
                }
                // 駒グループに追加
                komagroup.add(gltf.scene);
                },
            function (error) {
                console.log('An error happened');
                console.log(error);
                }
            );
        }
    }

うまくいくとこんな感じで表示されます。
それっぽい画面になっているのではないでしょうか。

f:id:kt-watson:20200622111932p:plain
初期配置

f:id:kt-watson:20200622112149p:plain
カメラの角度を変えた

「駒を置く」をどう表現するか

オセロゲームの重要な要素である「駒を置く」をどうやって実現するか・・・

ユーザーが唯一操作するのは、この駒を置くということだけです。
「駒を置く」ことの操作性に、今回作るゲームの評価がすべてがかかっていると言っても過言ではありません。

オセロゲームによくある「駒を置く」操作は、「駒を置きたい場所をクリックする」ではないでしょうか。
実際にオセロを遊ぶときと同じ操作感で、直感的でわかりやすいです。

なんとかそれを再現できないかと調べたところ、three.jsにレイキャストという機能があることがわかりました。
詳しい仕組みはよくわかりませんが、マウスをクリックした位置から光線を飛ばし、その光線にぶつかったオブジェクトの情報を返してくれる仕組みのようです。
これを利用すれば再現できそうでした。

参考記事 Three.jsでオブジェクトとの交差を調べる - ICS MEDIA

上段のコードでさらっと「その座標の値が0(何も置いていない)なら、駒3Dモデルを見えなくする」として、まだ駒が置いていない場所にも駒を配置しました。
つまり、目には見えませんが、駒オブジェクトはすでに盤上に実体化され配置されています。

ココすごく重要です。
テストに出ます。

クリックした場所に、駒オブジェクトがあるかどうか探索し、駒オブジェクトがあれば次の処理をする

というアルゴリズム(?)にすることで、理想に近い「駒を置く」操作を実現できそうです。

「クリックすると駒を置く」コードは以下のようになります。

function clickPosition( event) {
    const element = event.currentTarget.activeElement;
    // canvas要素上のXY座標
    if(event.type === 'mousedown'){
        x = event.clientX - element.offsetLeft;
        y = event.clientY - element.offsetTop;            
    }else{
        x = event.changedTouches[0].pageX - element.offsetLeft;
        y = event.changedTouches[0].pageY - element.offsetTop; 
    }

    // canvas要素の幅・高さ
    const w = element.offsetWidth;
    const h = element.offsetHeight;
         
    // マウスクリック位置を正規化
    var mouse = new THREE.Vector2();
    mouse.x = ( x / w ) * 2 - 1;
    mouse.y = -( y / h ) * 2 + 1;
         
    // Raycasterインスタンス作成
    var raycaster = new THREE.Raycaster();
    // 取得したX、Y座標でrayの位置を更新
    raycaster.setFromCamera( mouse, camera );
    // オブジェクトの取得
    var intersects = raycaster.intersectObjects( scene.getObjectByName("komagroup").children,true);

    if (intersects.length > 0){
        //盤面状況を更新し、画面を更新するコードを書く
    }
}

最後のraycaster.intersectObjects();の部分でクリックした場所にあるオブジェクト一覧を取得しているのですが、引数にscene.getObjectByName("komagroup").childrenと駒グループを指定することにより、レイキャストで探索するオブジェクトを駒だけにすることができます。

これで、駒を置きたい場所をクリックすれば、その場所の駒オブジェクトの情報が取得でき、盤面を表す二次元配列を更新するとで、オセロゲームを進めていく根幹の機能は表現できました。

オセロのルールに適応する

駒は置けるようになりましたが、このままでは64マスのどこでも置けてしまいます。
オセロには「必ず相手の駒を1つ以上ひっくり返せる場所にしか置けない」というルールがあるので、対応します。

このルールに対応するために、「自分の駒をある座標に置くことによりひっくり返すことができる、相手の駒のリスト」を返すsearchReverseKoma関数を作成します。

function searchReverseKoma(row,col,turn,board){

    return new Promise((resolve, reject) => {

    let search_direction = [];
    let reverse_komas = [];

    //すでに駒が置いてある場合は探索しない
    if(board[row][col] != 0) return resolve(reverse_komas);;

    // 探索する方向を決める
    for (let r of [-1,0,1]){
        for (let c of [-1,0,1]){
            try {
                let new_row = row + r;
                let new_col = col + c;

                if (new_row < 0 || new_col < 0) continue;
                if (board[new_row][new_col] === turn || board[new_row][new_col] === 0) continue;

                search_direction.push([r,c])

            }catch(error){
                continue;
            }
        }
    }

    // 実際に探索する
    for (let rc of search_direction){
        let reverse_koma = [];
        let r = rc[0];
        let c = rc[1];
        let new_row = row;
        let new_col = col;
        while(true){
            new_row += r;
            new_col += c;
            if ((new_row < 0 || new_row > 7) || (new_col < 0 || new_col > 7)) break;
            if (board[new_row][new_col] === -1 * turn){
                reverse_koma.push("koma" + new_row + new_col);
            }else if(board[new_row][new_col] === turn){
                reverse_komas.push(...reverse_koma);
                break;
            }else break;
                
        }    
    }
    return resolve(reverse_komas);
});
}

処理に時間がかかる関数のため、後々便利なように同期的に処理ができるようPromiseで実装しています。
いかにも文系的な(?)実装ですが、

  1. すでに駒が置いてあるマスの場合は処理ストップ
  2. 置こうとしている駒の1つ隣のマス、計8マスの駒を探索し、相手の駒が置いてある方向を探索
  3. 2.で求めた全探索候補について、順に辿っていき、自分の駒が出てくるまでその方向を探索する

みたいな感じでひっくり返せる相手の駒を探索しています。 この関数の返り値リストの要素の数が0ならば、「そのマスに駒を置いてもひっくり返せる相手の駒はない」ということになりルール違反になります。

その他細々とした機能など

パスする機能や、ランダム相手(白)に戦える機能、終局時に黒と白の数を数えて勝敗を表示する機能など細かいものはいくつかありますが、そちらはコードを見ていただくとしてここでは割愛します。

まとめ

隠されたバグはまだ残っているかもしれませんが、オセロAI大会では大きな支障もなく、無事に全対局で終局まで進ませることができました。
予想通り私のオセロAIは新人の伊藤くんにボロ負けして、あっさり1回戦敗退でした。
オセロゲームの方でバグが出なかったので、それだけを誇りに今後も頑張っていきたいと思います。(目から汗が(T_T))