MENU

JavaScriptCanvasミニゲームリバーシ

【JavaScript】 最弱リバーシを作る[3]:思考ルーチンを組み込む

更新日:2021/03/03

 

 

『リバーシを作る』の第三回目は、思考ルーチンを組み込み、コンピューターと対戦できるようにしてみます。

 

- 最弱リバーシを作るIndex -
【JavaScript】 最弱リバーシを作る[1]:DOM要素と描画
【JavaScript】 最弱リバーシを作る[2]:ゲーム状況の管理
■【JavaScript】 最弱リバーシを作る[3]:思考ルーチンを組み込む ← 今読んでいる記事

 

■お願い
去年ECMAScript2020を頑張って日本語訳しましたが、誰も見てくれません・・・
誰かみて!!
【JavaScript】 学習のためECMAScript2020を日本語訳してみました

単純な思考ルーチン

 

僕が昔作成したリバーシの思考ルーチンは、次のようなものでした。

 

  1. 一番多く取れる位置を選択
  2. ただし、角優先

 

思考ルーチンというほど大げさなものではありませんね…

 

ソースコード

 

最初の構想では、思考ルーチンを一つのオブジェクトとして独立させる予定でしたが、これまで作成してくたコードを大幅に修正する必要があることに気が付きました。

 

前回・前々回の記事を修正するのはめんどうなので、getGameDataManager関数内に入れ込みます。

 


/**
 * 盤面データの管理
 * @param drawCtrl
 */
const getGameDataManager = ( drawCtrl )=>{

    /* 省略(既存の処理) */

    return Object.freeze({

        /* 省略(既存のメソッド) */

            // 最弱思考ルーチン
        compThink:()=>{
            const free=[];
            let res;

                   // 置くことが可能な位置と、そのとき取れるコマを取得
            for( let row = 0 ; row < pieceNum ; row ++ ){
                for( let col = 0 ; col < pieceNum ; col ++ ){
                    if( (res = pointCheck( col , row , PLAYER_COMP , false )) !== false ){
                        const sum = res.reduce( (a,b)=>[ 0 , 0 , 0 , a[3] +b[3] ],[0,0,0,0])[3];
                        free.push( [ col , row , res , sum ] );
                    }
                }
            }
            if( free.length === 0 ) return false;

                   // 取得できるコマ数順に降順ソート
            free.sort( (a,b)=>b[3]-a[3] );

                   // 角がないかチェック
            for( let i = 0; i < free.length ; i ++ ){
                const [x,y] = free[i];
                if( (x === 0 && y === 0) ||  (x === 7 && y === 0)
                    ||  (x === 0 && y === 7) ||  (x === 7 && y === 7) ) return [x,y];
            }

                  // 一番とれる位置を返す
            return [ free[0][0] , free[0][1] ];
        }
    });
};

 

ソース解説

 

まずはコンピューター側が置くことができるコマ位置と、そのときに裏返せる相手のコマ数を取得します。

 


for( let row = 0 ; row < pieceNum ; row ++ ){
    for( let col = 0 ; col < pieceNum ; col ++ ){
        if( (res = pointCheck( col , row , PLAYER_COMP , false )) !== false ){
                const sum = res.reduce( (a,b)=>[ 0 , 0 , 0 , a[3] +b[3] ],[0,0,0,0])[3];
                free.push( [ col , row , res , sum ] );
         }
    }
}

 

pointCheck関数で返ってくる配列には、縦横斜め毎に取れるコマ数が格納されているので、reduceメソッドで合計しています。

 

このreduceのコールバック関数は、入力値が配列なので配列で返す必要があります。
また、合計を行う配列の要素数が1つのときを考慮して、初期値を指定しています。

 


const sum = res.reduce( (a,b)=>[ 0 , 0 , 0 , a[3] +b[3] ],[0,0,0,0])[3];

 

次に、取れるコマ数で降順ソートします。

 


free.sort( (a,b)=>b[3]-a[3] );

 

次に角が含まれているどうかをソートした配列でチェックし、最初に現れた位置を返します。

 

角が含まれていない場合は、ソートした配列の0番目を返します。

テスト

 

では思考ルーチンを組み込んだソースを使用して、テストしてみます。

 


window.addEventListener( "DOMContentLoaded" , ()=> {

    let player = PLAYER_YOU;
    let passWait = false,running = false,clickSetsWait = false,thinkWait = false;
    let freeSpace = null;

    const playerChange = () =>{
        player = (player === PLAYER_YOU ? PLAYER_COMP : PLAYER_YOU);
        messageCtrl.turnMessage( player );
        freeSpace = gameData.getFreePlaces( player );
        if( freeSpace === false ){  // 取れるコマがない
            passWait = true;
            messageCtrl.pass( player , ()=>{
                passWait = false;
                playerChange();
                // freeSpace===nullの場合、どちらもとれず終了
            });
        }else if( player === PLAYER_COMP ) think();
    };

    const putPiece = pos => {
        clickSetsWait=true;
        gameData.clickSets( pos[0] ,pos[1] , player ,
                 result => controlCtrl.result(result)
            ).then( result => {
                        controlCtrl.result(result);
                        clickSetsWait=false;
                        playerChange();
                    }
            );

    };
    
    const gameScreen =  makeGameBord( "reversi" );
    const messageCtrl =  getMessageControler( gameScreen.message );
    messageCtrl.message("開始ボタンを押してください");

    const controlCtrl =  getControlControler( gameScreen.control,
        ()=>{
            if( passWait || clickSetsWait ) return;
            if( running && !confirm("最初から始めますか?")) return;
            gameData.reset();
            gameData.pieceSet( 3 , 3 , PLAYER_COMP ).pieceSet( 4 , 4 , PLAYER_COMP )
                .pieceSet( 4 , 3 , PLAYER_YOU ).pieceSet( 3 , 4 , PLAYER_YOU );
            controlCtrl.result(gameData.result());
            running=true;
            player = PLAYER_YOU;
            messageCtrl.turnMessage(player);
            freeSpace = gameData.getFreePlaces(player);
        });
    
    const think = async () =>{

        const pos = gameData.compThink();
        await wait( 1000 );
        putPiece(pos);

    };

    const gameData = getGameDataManager(
        getMakeDrawingControler( gameScreen.bord.canvas,
            ( pos )=>{
                if( passWait || !running || player === PLAYER_COMP || clickSetsWait) return;

                const [col,row] = pos;
                if( freeSpace.some( e => e[0] === col && e[1] === row ) ) putPiece(pos);

            }
        )
    );

});

 

 

 

 

最初は強いと感じますが、何度かやってコツをつかむと、コンピューター側を全滅できます。
やっぱり最弱でした。

 

ほぼ完成しているように見えますが、勝敗を判断する処理が実装されていません。
ですが、記事としてはここまでとしたいと思います。

 

全ソース

 

今回作成した最弱リバーシの全ソースです。

 

記事での解説のしやすさを優先したため、非効率な面もあります。
それでも何らかの参考にしてみてください。

 

    "use strict";

(()=>{
    const option = {
        GAMEWIDTH : 300, // 画面の最大幅
        MESSAGE_HEIGHT : 50, // メッセージパネルの高さ
        CONTROL_HEIGHT : 50, // コントロールパネルの高さ
        BORD_FILL_COLOR: "#339933", // 盤面の色
        BORD_LINE_COLOR: "#000000", // 盤面の枠色
        BORD_LINE_WIDTH: 1,  // 盤面の境界線の太さ
        BORD_OUTERLINE_WIDTH: 4, // 盤面の外枠の太さ
        BORD_PIECE_NUM: 8,  // 横方向のコマ数
        PIECE_WIDTH_PER: 80, // 一枠の幅とコマの幅の割合
        PIECE_BLACK_COLOR: "#000000", // 黒コマの色
        PIECE_WHITE_COLOR: "#FFFFFF", // 白コマの色
    };
    Object.freeze( option );
    const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

    /**
    * DOM内にリバーシ用の要素を作成
    * @param id
    */
    const makeGameBord = id => {
    
        const setStyles = ( entity , styles ) => {
            Object.entries( styles ).forEach( e => entity.style[e[0]] = e[1] );
            return entity
        };
        const createDiv = ()=>
            setStyles( document.createElement("div") , {
                margin: 0,padding: 0,boxSizing: "border-box"} );
        
            // メッセージパネルとコントロールパネルの内側要素作成
        const [messageEntity,ctrlEntity] =
            [ [createDiv(),"パス"],[createDiv(),"開始"] ]
                .map( ([div,btnName]) => {
                    const result = {
                        inner: div ,p : document.createElement("p") ,
                        btn : setStyles( document.createElement("button") ,
                                {width:"50px",height:"50px",display:"none"})
                    };
                    result.btn.textContent = btnName;
                    setStyles( div , {display:"flex",justifyContent:"space-between"} );
                    div.appendChild( createDiv() ).appendChild(result.p);
                    div.appendChild(
                            setStyles(createDiv(),{width:"50px",height:"50px"} )
                        ).appendChild(result.btn);
                    return result;
                });
        
        const pDiv = document.getElementById(id);
        
        const gameWidth // 盤の幅を計算
            = Math.min( pDiv.clientWidth,option.GAMEWIDTH ) + option.MESSAGE_HEIGHT + option.CONTROL_HEIGHT < window.innerHeight
            ? Math.min( pDiv.clientWidth,option.GAMEWIDTH )
            : window.innerHeight - option.MESSAGE_HEIGHT - option.CONTROL_HEIGHT;
        
        const widthPixsel = gameWidth + "px";
        
        const screenObj = { };
        // ボードパネル、メッセージパネル、コントロールパネル要素作成
        [screenObj.message,screenObj.bord,screenObj.control] = (
            ( divStyles ) =>[
                [option.MESSAGE_HEIGHT,
                    div=>(div.appendChild(messageEntity.inner)  && messageEntity)],
                [gameWidth,div=>{ // キャンバスの設置
                    const cvs = setStyles( document.createElement("canvas" ) ,
                        { width : widthPixsel , height : widthPixsel ,
                        top :0 , left : 0,border:"0", position:"absolute",cursor:"pointer",
                        boxSizing : "border-box",padding:"0",margin:"0",zIndex:0} );
        
                    cvs.width = cvs.height =  option.GAMEWIDTH;
                    div.appendChild(cvs);
        
                    return  {canvas : cvs};
                }],
                [option.CONTROL_HEIGHT,
                    div=>(div.appendChild(ctrlEntity.inner)  && ctrlEntity)]
            ].map(
                ( [height ,  innerFunc ] )=>{
                    const addDiv = setStyles( document.createElement("div") , divStyles );
        
                    addDiv.style.height = height + "px";
                    const result = innerFunc(addDiv);
                    result.panel = pDiv.appendChild( addDiv );
                    return Object.freeze(result);
                }
            )
        )({position:"relative", padding:"0" , boxSizing : "border-box",
            border:"0",maxWidth:"100%",margin:"0 auto",width: widthPixsel });
        
        return Object.freeze( screenObj );
    };

    // ゲーム定数

    const PLAYER_NONE = Symbol();
    const PLAYER_YOU = Symbol();
    const PLAYER_COMP = Symbol();

    const getPlayText = ( player , txt) =>
        player === PLAYER_YOU ? txt[0] : txt[1];

    /**
     * メッセージパネルコントローラー
     * @param message
     */
    const getMessageControler = message =>{

        const { p , btn } = message;

        const msg = [["あなた(黒)の番です","考えています…"],["あなたがとれるコマがありません","こちらがとれるコマがありません"]];

        return Object.freeze({
            message : text => p.textContent = text,
            turnMessage : function( player ){ this.message(getPlayText( player , msg[0] )) },
            clear:function(){this.message("")},
            pass: function(player,callBack){
                this.message(getPlayText( player , msg[1] ));
                btn.style.display = "block";
                const cb = ()=>{btn.removeEventListener("click",cb);btn.style.display = "none";callBack();}
                btn.addEventListener("click",cb);
            }
        });
    };
    /**
     * コントロールパネルコントローラー
     * @param control
     * @param clickCallback
     */
    const getControlControler = ( control , clickCallback ) =>{
        const { p , btn } = control;
        btn.addEventListener("click",()=>clickCallback());
        btn.style.display="block";
    
        return Object.freeze({
               // ゲームの状況を表示 r[0] 残りコマ数 r[1] 対戦者のコマ数 r[2] コンピューターのコマ数
            result : r => p.textContent = `●${r[1]} ○${r[2]}`,
            clear:function(){ this.result(""); },
        });
    };
    /**
     * レイヤー描画操作ヘルパーオブジェクト
     * @param canvas キャンバス
     * @param clickCallback キャンバスクリック時に呼び出される関数
     */
    const getMakeDrawingControler = ( canvas , clickCallback ) => {

        const context = canvas.getContext( "2d" ); // コンテキスト取得
    
    
        const {GAMEWIDTH:bordSize , BORD_OUTERLINE_WIDTH:edgeSize} = option;
            // ブラウザ上の幅とキャンバス幅の比率
        const scale = canvas.width / canvas.clientWidth;
            // 外枠を除いた盤面幅
        const innerSize = bordSize - edgeSize * 2;
            // コマ枠の幅
        const pieceWidth = Math.floor( innerSize / option.BORD_PIECE_NUM );
            // 白黒コマの幅
        const pieceRadius = Math.floor(pieceWidth / 100 * option.PIECE_WIDTH_PER / 2);
            // コマ枠の中心点座標
        const pieceCenterPoint = edgeSize + Math.floor( pieceWidth / 2 );
        const pieceCenter = ( col , row ) => [ pieceWidth * col + pieceCenterPoint , pieceWidth * row + pieceCenterPoint];
    
            // クリック位置の取得
        const getClickPiece = e => {
                const rect = e.target.getBoundingClientRect();
                // ブラウザ座標→キャンバス上でのクリック座標計算
                let [x,y] = [ (e.clientX - rect.left)*scale ,(e.clientY - rect.top)*scale];
                if( x <= edgeSize || y <= edgeSize || x >= innerSize || y >= innerSize ) return null;
                x -= edgeSize; y -= edgeSize;// 外枠分差し引く
                return [Math.floor(x / pieceWidth) , Math.floor(y / pieceWidth )];
            };
    
            // キャンバスクリックイベントの登録
        canvas.addEventListener( "click" , (e)=>{
                const pNumber = getClickPiece(e);
                if( pNumber !== null ) clickCallback( pNumber );
            });
    
        const obj = {
            initBord : function (){
                this.clear();
    
                context.save();
    
                [context.fillStyle , context.strokeStyle , context.lineWidth ]
                    = [option.BORD_LINE_COLOR , option.BORD_LINE_COLOR , option.BORD_LINE_WIDTH];
                context.fillRect(0 , 0 , bordSize , bordSize);
                context.fillStyle = option.BORD_FILL_COLOR;
                context.fillRect( edgeSize , edgeSize , innerSize , innerSize);
    
                context.beginPath();
    
                for( let col = 1,posXY = edgeSize ; col < option.BORD_PIECE_NUM ; col ++ ){
                    posXY += pieceWidth;
                    context.moveTo(posXY,0);context.lineTo(posXY,bordSize);
                    context.moveTo(0,posXY);context.lineTo(bordSize,posXY);
                }
                context.stroke();context.restore();
            },
            clear:( ) => {
                context.clearRect( 0 , 0 , bordSize , bordSize);
                return this;
            },
            drawPiece :  ( col , row , player ) => {
    
                const center = pieceCenter(col , row);
                context.save();
                context.fillStyle = context.strokeStyle
                    = getPlayText(player,[option.PIECE_BLACK_COLOR,option.PIECE_WHITE_COLOR]);
                context.beginPath();
    
                context.arc( ...center , pieceRadius , 0, 2 * Math.PI ,false);
                context.fill();
    
                context.restore();
            },
        };
    
        obj.initBord();
        return Object.freeze(obj);
    };

    /**
     * 盤面データの管理
     * @param drawCtrl
     */
    const getGameDataManager = ( drawCtrl )=>{
    
        const pieceNum = option.BORD_PIECE_NUM;
    
    
            // ゲーム状況データ作成 横、縦の2次元配列
        const data = Array.from({length:8},
            e => new Array(pieceNum).fill(PLAYER_NONE) );
    
            // 指定位置(col , row)から指定方向(stepX , stepY)でコマがとれるかチェック
        const checkPiece = ( col , row ,stepX , stepY , nowPlayer ) =>{
            let x = col + stepX,y = row + stepY;
            let firstPos = null;
            let count = 0;
    
            while( true ){
                if( x < 0 || x >= pieceNum || y < 0 || y >= pieceNum ) return false;
                const stat = status(x,y);
                if( stat === PLAYER_NONE ) return false;
    
                if( stat === nowPlayer )
                    return firstPos === null ? false
                        : [firstPos,[x-=stepX,y-=stepY],[stepX,stepY],count]; // 始点、終点、方向
    
                if( firstPos === null ) firstPos = [x,y];
                count++;
    
                x += stepX;y += stepY;
    
            }
        };
    
            // 指定位置の状況を取得
        const status = ( col , row ) =>  data[row][col];
        const statusSet = ( col , row ,val ) =>  data[row][col] = val;
    
            // 指定位置にコマを置けるかどうかチェック
        const pointCheck = ( col , row , player , checkOnly = true) => {
    
            const piece = status( col , row );
            if( piece !== PLAYER_NONE ) return false;
            const nextPlayer = player === PLAYER_YOU ? PLAYER_COMP : PLAYER_YOU;
            const result = [];
    
            return ( [[-1,-1],[0 , -1],[1 , -1],[1 , 0],[1 , 1],[0 , 1],[-1 , 1],[-1 , 0]]
                    .some( e => {
                        const range = checkPiece( col , row , e[0] , e[1] , player , nextPlayer );
                        if( range !== false ){
                            if( checkOnly ) return true;
                            result.push(range);
                        }
                    })
            ) ? true : ( result.length === 0 ? false : result );
    
        };
    
    
    
        return Object.freeze({
                // ゲーム状況データのリセット
            reset:()=>{data.forEach(e=>e.fill(PLAYER_NONE));drawCtrl.initBord()},
                // コマの配置と描画
            pieceSet:function( col , row , val ){
                statusSet(col,row, val);
                if( drawCtrl !== null ) drawCtrl.drawPiece( col , row , val );
                return this;
            },
                // コマを置ける位置を検索
            getFreePlaces: ( player ) => {
                const result=[];
                for( let row = 0 ; row < pieceNum ; row ++ ){
                    for( let col = 0 ; col < pieceNum ; col ++ ){
                        if( pointCheck( col , row , player ) !== false ) result.push( [col,row] );
                    }
                }
                return result.length === 0 ? false : result;
            },
                // 現在の状況
            result: ()=>{
                let c1 = 0,c2 = 0,c3 = 0;
                for( let row = 0 ; row < pieceNum ; row ++ ){
                    for( let col = 0 ; col < pieceNum ; col ++ ){
                        switch( status( col , row ) ){
                            case PLAYER_NONE:c1++;break;
                            case PLAYER_YOU:c2++;break;
                            case PLAYER_COMP:c3++;break;
                        }
                    }
                }
                return [c1,c2,c3];
            },
                // 指定した位置にコマを置き、裏返し可能なものを処理
            clickSets: async function( col , row , player,callBack){
    
                const range = pointCheck( col , row , player , false );
                this.pieceSet( col , row , player);
                if( range === false ) return;
                for( let i = 0 ; i < range.length ; i ++ ){
                    let [colp , rowp] = range[i][0];
                    const [stX , stY] = range[i][2];
                    const [endColp , endRowp] =
                        [range[i][1][0] + stX , range[i][1][1] + stY];
    
                    while( colp !== endColp || rowp !== endRowp ){
                        await wait(500);
                        this.pieceSet( colp , rowp , player);
                        callBack(this.result());
                        colp += stX;rowp += stY;
                    }
                }
                return this.result();
            },
                // 最弱思考ルーチン
            compThink:()=>{
                const free=[];
                let res;
    
                for( let row = 0 ; row < pieceNum ; row ++ ){
                    for( let col = 0 ; col < pieceNum ; col ++ ){
                        if( (res = pointCheck( col , row , PLAYER_COMP , false )) !== false ) {
                            const sum = res.reduce( (a,b)=>[ 0 , 0 , 0 , a[3] +b[3] ],[0,0,0,0])[3];
                            free.push( [ col , row , res , sum ] );
                        }
                    }
                }
                if( free.length === 0 ) return false;
                free.sort( (a,b)=>b[3]-a[3] );
                for( let i = 0; i < free.length ; i ++ ){
                    const [x,y] = free[i];
                    if( (x === 0 && y === 0) ||  (x === 7 && y === 0)
                        ||  (x === 0 && y === 7) ||  (x === 7 && y === 7) ) return [x,y];
                }
                return [ free[0][0] , free[0][1] ];
            }
        });
    };
    
    window.addEventListener( "DOMContentLoaded" , ()=> {
        let player = PLAYER_YOU;
        let passWait = false,running = false,clickSetsWait = false,thinkWait = false;
        let freeSpace = null;
    
        const playerChange = () =>{
            player = (player === PLAYER_YOU ? PLAYER_COMP : PLAYER_YOU);
            messageCtrl.turnMessage( player );
            freeSpace = gameData.getFreePlaces( player );
            if( freeSpace === false ){  // 取れるコマがない
                passWait = true;
                messageCtrl.pass( player , ()=>{
                    passWait = false;
                    playerChange();
                    // freeSpace===nullの場合、どちらもとれず終了
                });
            }else if( player === PLAYER_COMP ) think();
        };
    
        const putPiece = pos => {
            clickSetsWait=true;
            gameData.clickSets( pos[0] ,pos[1] , player ,
                     result => controlCtrl.result(result)
                ).then( result => {
                            controlCtrl.result(result);
                            clickSetsWait=false;
                            playerChange();
                        }
                );
    
        };
    
        const gameScreen =  makeGameBord( "reversi" );
        const messageCtrl =  getMessageControler( gameScreen.message );
        messageCtrl.message("開始ボタンを押してください");
    
        const controlCtrl =  getControlControler( gameScreen.control,
            ()=>{
                if( passWait || clickSetsWait ) return;
                if( running && !confirm("最初から始めますか?")) return;
                gameData.reset();
                gameData.pieceSet( 3 , 3 , PLAYER_COMP ).pieceSet( 4 , 4 , PLAYER_COMP )
                    .pieceSet( 4 , 3 , PLAYER_YOU ).pieceSet( 3 , 4 , PLAYER_YOU );
                controlCtrl.result(gameData.result());
                running=true;
                player = PLAYER_YOU;
                messageCtrl.turnMessage(player);
                freeSpace = gameData.getFreePlaces(player);
            });
    
        const think = async () =>{
    
            const pos = gameData.compThink();
            await wait( 1000 );
            putPiece(pos);
    
        };
    
        const gameData = getGameDataManager(
            getMakeDrawingControler( gameScreen.bord.canvas,
                ( pos )=>{
                    if( passWait || !running || player === PLAYER_COMP || clickSetsWait) return;
    
                    const [col,row] = pos;
                    if( freeSpace.some( e => e[0] === col && e[1] === row ) ) putPiece(pos);
    
                }
            )
        );
    
    });
})();

 

html

 


<div id="reversi" ></div>

記事の内容について

 

こんにちはけーちゃんです。
説明するのって難しいですね。


「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。

そんなときは、ご意見もらえたら嬉しいです。

ご意見はこちら。
https://note.affi-sapo-sv.com/info.php

【お願い】

お願い

■このページのURL


■このページのタイトル


■リンクタグ