【JavaScript】 最弱リバーシを作る[1]:DOM要素と描画
更新日:2021/03/03
以前、僕が若いころ作ったミニゲームとして15スライドパズルを紹介しました。
■【JavaScript】 15スライドパズルを作る[1]:キャンバスと背景
今回はその次に作って友達にやってもらったら、史上最弱と言われたリバーシをJavaScriptで作成してみます。
- 最弱リバーシを作るIndex -
■【JavaScript】 最弱リバーシを作る[1]:DOM要素と描画 ← 今読んでいる記事
■【JavaScript】 最弱リバーシを作る[2]:ゲーム状況の管理
■【JavaScript】 最弱リバーシを作る[3]:思考ルーチンを組み込む
オプション定数
まずは画面の最大幅などを定数として定義しておきます。
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 );
ゲームの画面構成として、リバーシ盤の上にメッセージパネルを、下にコントロールパネル設置しています。
この条件で、画面の構成および描画に必要な情報を定数として指定しています。
PIECE_WIDTH_PERは、次の図ようなイメージで、①と②の割合でコマの直径を計算するために使用します。
その他の定数はコメントを読んでもらえればわかると思います。
最後のObject.freezeメソッドは、オブジェクトのプロパティを書き込み不可にしています。
ゲームを作りこんでいくと、オプション値を変更可能にして、汎用的にしたいと思いますよね。
そんなときは、Object.freezeメソッドを削除するのではなくて、次のように値をコピーして使用することをおススメします。
const optionMerge = opt => Object.assign( Object.assign({},option), opt );
const newOption = optionMerge({MESSAGE_HEIGHT:500}) );
DOM要素の作成
次は、ゲームに必要なDOM要素(htmlタグ)を、動的に作成します。
ゲームを設置するhtmlには、目印として次のタグを設置しておきます。
html
<div id="reversi" ></div>
このタグの中に、次のようなイメージでタグを挿入していきます。
メッセージパネル(上側)のボタンはピースが置けない時のパスボタンを、コントロールパネル(下側)のボタンはゲームのスタートボタンです。
ソースコード
単純なDOM要素を既存の要素に挿入する場合、次のようにタグ文字列を直接innerHTMLに代入するのが手っ取り早いです。
const pDiv = document.getElementById(id);
pDiv.innerHTML = "<div><div><div><p></p></div><div><button></button></div></div></div><div><canvas></canvas></div><div><div><div><p></p></div><div><button></button></div></div></div>";
後は、親要素からquerySelectorメソッド等で必要な要素を取得します。
とてもシンプルでわかりやすいコードに仕上がると思います。
ですが今回はJavaScriptの学習という意味をこめて、要素を一つ一つ作成し、スタイル属性もコード上で設定していく形式をとっていこうと思います。
/**
* 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); // 引数で与えらえたdiv要素を取得
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 );
};
上のコードは、JavaScriptの機能をできる限り詰め込もうとした結果、一見わかりにくいものになっています。
ですが構造がつかめれば、単純なものに感じてもらえると思います。
ソース解説
上から順番に解説してみます。
- setStyles関数
const setStyles = ( entity , styles ) => { Object.entries( styles ).forEach( e => entity.style[e[0]] = e[1] ); return entity };
setStyles関数は、引数で与えられたDOM要素にオブジェクトで与えられたスタイル属性をセットし、そのDOM要素をそのまま返しています。
- createDiv関数
const createDiv = ()=> setStyles( document.createElement("div") , { margin: 0,padding: 0,boxSizing: "border-box"} );
createDiv関数は、div要素を作成してマージン等のスタイルをセットしています。
- メッセージパネルとコントロールパネルの内側要素作成
// メッセージパネルとコントロールパネルの内側要素作成 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; });
このコードを実行すると、次の要素が二つ作成されます。
作成される要素
<div> <div><p></p></div> <div><button></button></div> </div>
このコードを単純化すると、次のような構造になっています。
const [変数,変数] = [ 値 , 値 ].map( コールバック関数 );
まずは [ 値 , 値 ].map( コールバック関数 )が実行されます。mapメソッドは、全ての配列要素に対してコールバック関数を実行し、コールバック関数が返した値で新規配列を作成します。
今回のコードでは、createDiv()の結果と文字列を配列にし、それを一つの要素として扱っています。
そしてコールバック関数の引数もまた配列で記述されています。このとき、次のような代入操作が行われています。
[div,btnName] = [createDiv(),"パス"]
これは分割代入と呼ばれるもので、対応する配列要素を変数に代入します。
つまり関数コードの中では、divおよびbtnNameという個別の名前の変数として使用できます。コールバック関数は処理結果として、次のようなオブジェクトを返しています。
{ inner: div要素, p : p要素 , btn : button要素}
その結果、mapメソッドの実行結果が、オブジェクト要素を二つ持つ配列になり、次のコードの処理に移ります。
const [変数,変数] = [ オブジェクト , オブジェクト ]
この形式は、先ほどの分割代入です。
分割代入は、対応する配列要素を変数に代入します。つまり、次のコードと同等です。
const obj = [ createDiv(),createDiv() ].map(/* 省略 */); const messageEntity = obj[0]; const ctrlEntity = obj[1];
- ゲーム盤の幅計算
次に、表示するブラウザの画面サイズを取得し、ゲーム盤と上下のパネルが一画面内に収まるようにゲーム盤の幅を計算しています。
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;
dom要素.clientWidth:要素の内側幅(ピクセル)
window.innerHeight:ビューポートの高さ(ピクセル)ここでは3項演算子を使用しています。
- パネル設置
次に、3枚のパネルを設置します。
// ボードパネル、メッセージパネル、コントロールパネル要素作成 [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 });
このコードは、 メッセージパネルとコントロールパネルを作成したときのものに似ていますが、少し余計なことをしています。
単純化すると、次のようになります。
[screenObj.message , screenObj.bord , screenObj.control] =
( ( divStyles )=>[ 要素 , 要素 , 要素 ]
.map( 要素 => コールバック関数 )
)( {/*省略*/} );これは何でしょうか?
これは即時関数(青色部分)の結果([].map()の結果)を、分割代入で分割し、オブジェクトのプロパティにセットしています。では、即時関数の中身を見てみます。
関数の中身では、要素を3つ持つ配列に対してmapメソッドが実行され、その結果が関数の結果として返されています。mapメソッドは引数として配列の要素を受け取ります。
今回はパネルの高さと内部要素を作成する関数オブジェクトがセットされた配列を受け取り、その情報を元にdiv要素を作成しています。今回のコードの中に、次のようなものがあります。
これは何でしょう?(div.appendChild(messageEntity.inner) && messageEntity)
&&演算子は左側が偽と判断される場合、左側の値を返します。
真と判断される場合、右側の値を返します。div.appendChild(messageEntity.inner)の結果は、dom要素であり、真と判断されます。
よって、右側のmessageEntityが演算結果として返ります。使い方によっては便利ですが偽として判断される範囲が広いため、使用するかどうかは慎重に検討する必要があります。
というか値が不特定な場合、ほとんどのケースで自爆します。
なお、キャンバスのサイズ設定は考え方が少し複雑です。
次のページを読んでみてください。
【JavaScript】 Canvasのサイズが難解で困る
実行結果
まず上のコードは、最終的に次のようなオブジェクトを返しています。
{
message:{
panel: div要素,
inner: div要素,
p: p要素,
btn: button要素,
},
bord:{
panel: div要素,
canvas: canvas要素,
},
control:{
panel: div要素,
inner: div要素,
p: p要素,
btn: button要素,
},
}
メッセージパネルの制御
次はメッセージパネルの処理を作成します。
メッセージパネルにはメッセージを表示するp要素と、コマがとれないとき押すbtn要素があります。
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({
// p要素にメッセージを表示する
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);
}
});
};
最初のPLAYER_YOUとPLAYER_COMPは、対戦者とコンピューターを表す定数です。
Symbolで定数を作成することで、値が重複しないように制御できます。
getMessageControler関数のmessage引数は、DOM要素の作成の実行結果のmessageです。
分割代入により、messageのプロパティを変数に割り当てています
passメソッドはパスボタンの制御のみおこないます。
ゲーム全体のアクティブ化・非アクティブ化などはおこなわず、ボタンが押されたことをcallBack引数で与えられた関数で通知するだけです。
コントロールパネルの制御
次はコントロールパネルの処理を作成します。
コントロールパネルには対戦状況を表示するp要素と、スタート時に押すbtn要素があります。
/**
* コントロールパネルコントローラー
* @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("") },
});
};
getControlControler関数のcontrol引数は、DOM要素の作成の実行結果のcontrolです。
分割代入により、controlのプロパティを変数に割り当てています。
引数clickCallbackは、ボタンが押されたときに通知するコールバック関数です。
ゲームの状況に関係なく、通知します。
キャンバスの制御
次はキャンバス上での処理を作成します。
キャンバスについては、次のページを読んでみてください。
■【JavaScript】 Canvasの使い方まとめ
ソースコード
ここでは盤面の描画、コマの描画、キャンバス上でのクリック通知を行います。
/**
* レイヤー描画操作ヘルパーオブジェクト
* @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);
};
ソース解説
getMakeDrawingControler関数のcanvas引数は、DOM要素の作成の実行結果のbord.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 );
次のpieceCenterは、縦横の番号(0~7)から、丸いコマを描画する中心座標を計算しています。
// コマ枠の中心点(絶対)座標
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 );
});
最後にキャンバスに描画を行うメソッドをオブジェクトに作成して、初期盤面を描画し、オブジェクトを返しています。
テスト
ここまで作成してきたコードを使用して、テストをおこなってみます。
次のような条件でテストコードを作成してみます。
- キャンバスをクリックするたびに黒白入れ替わる
- 6回ごとにパスボタンが表示される
- 開始ボタンはアラートのみ
window.addEventListener( "DOMContentLoaded" , ()=> {
let player = PLAYER_YOU;
let busy = false;
let count = 0;
const playerChange = () =>
player = (player === PLAYER_YOU ? PLAYER_COMP : PLAYER_YOU);
const gameScreen = makeGameBord( "reversi" );
const messageCtrl = getMessageControler( gameScreen.message );
const controlCtrl = getControlControler( gameScreen.control,
()=>alert("スタートボタンが押された") );
messageCtrl.turnMessage(player);
const drawCtrl = getMakeDrawingControler( gameScreen.bord.canvas,( pos )=>{
if( busy ) return;
drawCtrl.drawPiece( pos[0],pos[1],player );
playerChange();
if( ++count > 5 ) {
busy = true;
messageCtrl.pass( player , ()=>{
playerChange();
count = 0;busy = false;
messageCtrl.turnMessage( player );
});
}else{
messageCtrl.turnMessage( player );
}
});
});
下は、実際に動作します。
確認してみてください。
更新日:2021/03/03
関連記事
スポンサーリンク
記事の内容について
こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。