JavaScript (+LWC)のチュートリアル: リバーシを作る(2)
概要
今回が単純なリバーシの実装と、シンプルな画面表示まで。 画面はこだわったものではなく、単純に文字表示のもの。つまり UI ではなく、ゲームのルール実装を優先します。
前提知識
前回までの知識
目次
ディレクトリ構造を変更する
前回までで LWC を設定しましたはが、「example」ディレクトリって…と思ったのは自分だけか?
ということで、これをまず変更する。
具体的にはこんな感じになっているので
- modules
- example
- app
- example
これをこうしたい
- modules
- reversi
- app
- reversi
これ自体は簡単なので、とりあえずディレクトリ名を変更しよう。
これだけで、 npm run dev
でサーバを起動すると…
500 - Error retrieving view for route "example"
と言われてしまいます。
これは example/app
をアプリケーションの起動ルートとして設定されているからですね…
この設定ファイルは lwr.config.json
ファイルで、こんな感じで書き換えます。
{ "lwc": { "modules": [{ "dir": "$rootDir/src/modules" }] }, "routes": [ { "id": "reversi", "path": "/", "rootComponent": "reversi/app" } ] }
その後再起動してみてください。前回までの画面が見えたと思います。
ロジックの実装
まずはリバーシのボードを表示しよう
MVC の考え方により、ロジックは独立して設計します。
そのため、まずはロジッククラスを用意します。
- modules
- reversi
- app (既存ディレクトリ)
app.css
app.html
app.js
- logic
logic.js
- app (既存ディレクトリ)
- reversi
リバーシの盤面は二次元配列で用意しましょう。
const BOARD_WIDTH = 8; const BOARD_HEIGHT = 8; const BOARD_EMPTY = 0; const BOARD_BLACK = 1; const BOARD_WHITE = 2; class Reversi { board = []; constructor() { this.initBoard(); } initBoard() { // シンプルにボードの高さ、幅で二次元配列を作る const root = []; for (let y=0; y<BOARD_HEIGHT; y++) { const row = []; for (let x=0; x<BOARD_WIDTH; x++) { row.push(BOARD_EMPTY); } root.push(row); } // 真ん中に初期配置の 白/黒 を置く const X_CENTER = BOARD_WIDTH / 2; const Y_CENTER = BOARD_HEIGHT / 2; // 配列は 0 から開始なので、4 は画面左から 5 マス目 root[X_CENTER - 1][Y_CENTER - 1] = BOARD_BLACK; root[X_CENTER][Y_CENTER - 1] = BOARD_WHITE; root[X_CENTER - 1][Y_CENTER] = BOARD_WHITE; root[X_CENTER][Y_CENTER] = BOARD_BLACK; this.board = root; } } export default Reversi;
頭のなかでイメージできますか?…慣れるまで難しいですよね…
これを画面に表示してみましょう。次は app.js
を編集します。
import { LightningElement } from 'lwc'; import ReversiLogic from 'reversi/logic'; export default class HelloWorldApp extends LightningElement { reversiLogic = new ReversiLogic(); get boardRows() { const rowToStr = (rowArr) => { return rowArr.map(n => { // 1 マス単位で文字に置き換えます return n === 0 ? ' ' : (n === 1 ? '●' : '○'); }).join(' / '); // 最後に ` / ` で1行分を1文字列に連結 }; let key = 0; return this.reversiLogic.board .map(rowToStr) // 1 行単位を文字列化 .map(v => { // 1 行単位を { key: 行番号, value: 行の中身 } に整形 return { key: key++, value: v }; }); } }
画面は見た目にこだわらずそのままループで表示します。
プログラマならおなじみの foreach
文ですね。
<template> <main> <div style="display: inline-block; border: 1px solid #000;"> <template for:each={boardRows} for:item="row"> <div key={row.key}>{row.value}</div> </template> </div> </main> </template>
石を置けるようにしよう
石を裏返す動作をとりあえずは考えず、石を置けるようにします。
ボードに石を置いたほうが利用者的にはわかりやすいですが、今回はロジック優先なので…。
まずは、ロジック的に、白のターン/黒のターンを定義しましょう。
ついでに石を配置するロジックも。
class Reversi { board = []; isBlackTurn = true; /** * 石を置く座標、それが黒石かどうか * @param {number} x * @param {number} y * @param {boolean} isBlack * @returns error message */ putStone(x, y, isBlack) { if (this.board.at(y)?.at(x) === null) { // 値が取得できない = 盤面の外 return 'そんな所に石は置けない!'; } if (this.board[y][x] === BOARD_EMPTY) { // ボードの指定座標が空なら // 引数で「黒石か?」で黒なら黒石、でなければ白石を設定 const putStone = isBlack ? BOARD_BLACK : BOARD_WHITE; // 石を盤面に配置 this.board[y][x] = putStone; // ターンを変更する this.isBlackTurn = !isBlack; return null; } else { // ボードの指定座標は空ではなかった return 'そこには既に石があります。'; } } /* 以下略 */
石を裏返す動作は一旦考えません。
なので、とりあえずは置くことだけ考えます。
次に、画面で黒のターン、白のターン表示を行います。
app.js
に下記のメソッドを追加して、
get isBlackTurn() { return this.reversiLogic.isBlackTurn; }
画面上は
<template if:true={isBlackTurn}><div>黒のターンです!</div></template> <template if:false={isBlackTurn}><div>白のターンです!</div></template>
次は石を置きましょうか
石の定義はロジックにあるので、それを外に出しましょう。
logic.js
を修正しましょうか
const BOARD_WIDTH = 8; const BOARD_HEIGHT = 8; const BOARD_EMPTY = 0; const BOARD_BLACK = 1; const BOARD_WHITE = 2; const Constants = { BOARD_WIDTH, BOARD_HEIGHT, BOARD_EMPTY, BOARD_BLACK, BOARD_WHITE }; /* 中略 */ export { Constants }; export default Reversi;
export
で定数オブジェクトを外から呼べるようにしてやります。
続いて、app.js
では
import { LightningElement, track } from 'lwc'; import ReversiLogic from 'reversi/logic'; import { Constants } from 'reversi/logic'; // new export default class HelloWorldApp extends LightningElement { reversiLogic = new ReversiLogic(); constants = { // new 画面に画面サイズを渡す用 MAX_WIDTH: Constants.BOARD_WIDTH, MAX_HEIGHT: Constants.BOARD_HEIGHT } errorMessage = null; // new エラーメッセージ表示用 /* 中略 */ handleStonePut() { // new 石置き操作イベント // 一旦は空 } }
そして、現状の LWC だと @track
で修飾がなければ自動で画面がリフレッシュしないので
import { LightningElement, track } from 'lwc'; // track を追加 import ReversiLogic from 'reversi/logic'; import { Constants } from 'reversi/logic'; export default class HelloWorldApp extends LightningElement { reversiLogic = new ReversiLogic(); errorMessage = null; @track board = this.reversiLogic.board; // 追加 @track isBlackTurn = this.reversiLogic.isBlackTurn; // 追加 constants = { MAX_WIDTH: Constants.BOARD_WIDTH, MAX_HEIGHT: Constants.BOARD_HEIGHT } get boardRows() { const rowToStr = (rowArr) => { return rowArr.map(n => { return n === 0 ? ' ' : (n === 1 ? '●' : '○'); }).join(' / '); }; let key = 0; return this.board.map(rowToStr).map(v => { // 修正 return { key: key++, value: v }; }); } // get isBlackTurn() { // この getter は削除します。
そして画面を弄ります。
エラーがあればそれを表示し、面倒ではありますが座標指定を指定して送信ボタンを押すようにしましょう。
<template if:true={errorMessage}> <div style="background-color: red; padding: .25rem; text-align: center;">{errorMessage}</div> </template> <!-- ここまで --> <template if:true={isBlackTurn}><div>黒のターンです!</div></template> <template if:false={isBlackTurn}><div>白のターンです!</div></template> <!-- ここから追加 --> <div><label for="y">縦軸</label><input type="number" name="axis_y" min="1" max={constants.MAX_HEIGHT} id="y"></div> <div><label for="x">横軸</label><input type="number" name="axis_x" min="1" max={constants.MAX_WIDTH} id="x"></div> <button type="button" onclick={handleStonePut}>送信</button>
イベントも {関数名}
でJsの変数や関数を HTML に設定(バインドといいます。以降バインドと呼称)します。
ではここで、handleStonePut
の中身を作っていきましょう。
/** * 石を置く座標、それが黒石かどうか * @param {number} x * @param {number} y * @param {boolean} isBlack * @returns error message */ putStone(x, y, isBlack) { if (this.board.at(y)?.at(x) === null) { return 'そんな所に石は置けない!'; } if (this.board[y][x] === BOARD_EMPTY) { const putStone = isBlack ? BOARD_BLACK : BOARD_WHITE; this.board[y][x] = putStone; this.isBlackTurn = !isBlack; return null; } else { return 'そこには既に石があります。'; } }
するとこんな感じで表示できるようになります。
おっと黒のターンが続いているように見えますね。
原因は下記ですね。
@track board = this.reversiLogic.board; @track isBlackTurn = this.reversiLogic.isBlackTurn;
board
は Array
つまりオブジェクトですが、isBlackTurn
は boolean
なんですね。
JavaScript の変数は、オブジェクトは参照、つまりメモリの何処かに存在している実態のメモリアドレス*1。boolean
や number
などプリミティブ型は実際の値が入っています。
なので、ここでは以下のような解釈になります。
// この行は this.reversiLogic.board のメモリアドレスが board に入ってる。 // なので、this.board.length は間接的に this.reversiLogic.board.length を指す。 @track board = this.reversiLogic.board; // この行は、this.reversiLogic.isBlackTurn の値をコピーして isBlackTurn に入れている。 // なので、isBlackTurn と this.reversiLogic.isBlackTurn は別のものになっている。 // this.reversiLogic.isBlackTurn がどんな値となっていても、 isBlackTurn は独立してるから変化しなかった。 @track isBlackTurn = this.reversiLogic.isBlackTurn;
つまり、ターンが変わったら、再度設定してあげれば良いのですね。
handleStonePut() { try { const xValue = this.template.querySelector('input[name="axis_x"]').value; const yValue = this.template.querySelector('input[name="axis_y"]').value; this.errorMessage = this.reversiLogic.putStone(xValue - 1, yValue - 1, this.isBlackTurn); this.isBlackTurn = this.reversiLogic.isBlackTurn; // 追加 } catch (error) { console.log(error.message); } }
すると
石をひっくり返そう
では石をおいたら、その間の石をひっくり返すロジックを考えます。
図では白石の上に黒石を置いたので、下方向で石の色を変える事を考えます。
ルールは ○●○
などの挟まれた区間があれば色を置き換えるという処理になります。
これを1ますづつ評価するなら以下のように考えます。
石を置く座標(currentX
, currentY
)から下へ一つづつ下がり、以下いずれかの条件が整うまで繰り返します。
- 石を置いた座標(起点)は特にチェックしない
- 次の座標(一つ下)を確認する。
- 空白が出現する
- つまり裏返せる石が存在しない
- 盤面の端まで来ている
- これも裏返せる石が存在しない
- 敵対する石がある
- 裏返す対象の石。なのでさらに下をチェックする
- 自分と同じ色の石がある
- 敵対する石が存在しないなら、返せる石はない
- 敵対する石が1つ以上あるなら、石をひっくり返す
「条件を繰り返す」「同じ処理を繰り返す」は自分なら再起しますね。
ということで、上記をほとんどそのままコーディングします。
currentX, currentY
はチェックする座標vX, vY
次にチェックする座標の相対位置(v
はベクトル≒方向性)putColor
は置いた石の色depth
は何個先まで見に行ったのかの値
flipStone(currentX, currentY, vX, vY, putColor, depth) { let result = 0; // 画面外に出たら強制終了 if (currentX < 0 || currentX >= BOARD_WIDTH || currentY < 0 || currentY >= BOARD_HEIGHT) { return 0; } if (depth === 0) { // 起点座標は評価しない(putColor 置いた座標が putColor と一致するのは自明なので) // 次の座標をチェックする result = this.flipStone(currentX + vX, currentY + vY, vX, vY, putColor, depth + 1); } else { // それ以外は評価する // 現在の座標のマスを取得 const current = this.board.at(currentY)?.at(currentX); // 画面端 → 返す石はない if (current === null) { return 0; } // 空白が出現 → 返す石はない if (current === BOARD_EMPTY) { return 0; } // 同じ色の石がある if (current === putColor) { return depth - 1; } // 上記どれでもない → 敵対色の石がある(もしくは起点である) // 次の座標をチェックする result = this.flipStone(currentX + vX, currentY + vY, vX, vY, putColor, depth + 1); if (result > 0) { // 返す石がある → 現在の座標の石を置き換えて return this.board[currentY][currentX] = putColor; } } return result; }
この処理を石を置いた瞬間に起動します。
putStone(x, y, isBlack) { if (this.board.at(y)?.at(x) === null) { return 'そんな所に石は置けない!'; } if (this.board[y][x] === BOARD_EMPTY) { const putStone = isBlack ? BOARD_BLACK : BOARD_WHITE; this.board[y][x] = putStone; let revStones = this.flipStone(x, y, 0, 1, putStone, 0); // 下方向 revStones += this.flipStone(x, y, 0, -1, putStone, 0); // 上方向 revStones += this.flipStone(x, y, -1, 0, putStone, 0); // 左方向 revStones += this.flipStone(x, y, 1, 0, putStone, 0); // 右方向 revStones += this.flipStone(x, y, -1, -1, putStone, 0); // 左上方向 revStones += this.flipStone(x, y, 1, -1, putStone, 0); // 右上方向 revStones += this.flipStone(x, y, -1, 1, putStone, 0); // 左下方向 revStones += this.flipStone(x, y, 1, 1, putStone, 0); // 右下方向 console.log(`flips: ${revStones}`); this.isBlackTurn = !isBlack; return null; } else { return 'そこには既に石があります。'; } }
とすると石の反転動作が作れます。
ちなみに、同じ様なコードが沢山あるのは精神的によろしくない…
ので一旦リファクタ(リファクタはコードを圧縮して短くする…というのは間違いで、コードを整理することを言いますね。本当はテストコード書いてからやるものですが…)
putStone(x, y, isBlack) { if (this.board.at(y)?.at(x) === null) { return 'そんな所に石は置けない!'; } if (this.board[y][x] !== BOARD_EMPTY) { return 'そこには既に石があります。'; } const vectors = [ [0, 1], [0, -1], // 下, 上 [1, 1], [1, -1], // 右下, 右上 [-1, 1], [-1, -1], // 左下, 左上 [1, 0], [-1, 0] // 右, 左 ]; // 置く石の色 const putStone = isBlack ? BOARD_BLACK : BOARD_WHITE; // 石を配置 this.board[y][x] = putStone; // 石を反転操作(全方向) let revStones = 0; vectors.forEach(v => { revStones += this.flipStone(x, y, v[0], v[1], putStone, 0); }); // ターン変更 this.isBlackTurn = !isBlack; return null; }
石が置けない事を通知する
リバーシは石が裏返せる場所にしか石を置くことができません。
なので、初手で角に石を置くといった手順は取ってはならないのです。
ということで、裏返せる石がなかったときの処理を入れましょうか。
// 石を反転操作(全方向) let revStones = 0; vectors.forEach(v => { revStones += this.flipStone(x, y, v[0], v[1], putStone, 0); }); // 裏返せる石が無いなら、石は置けない if (revStones === 0) { // 追加分岐 this.board[y][x] = BOARD_EMPTY; return '裏返せる石が無いため、配置できません。'; } // ターン変更 this.isBlackTurn = !isBlack; return null;
また、どこにも石が置けないなら、手番をスキップするしかないですよね?
ということで手番スキップ処理を入れます。
class Reversi { board = []; isBlackTurn = true; constructor() { this.initBoard(); } skipTurn() { // 追加 this.isBlackTurn = !this.isBlackTurn; }
app.js
や app.html
でも追加しましょう。
handleSkip() { this.reversiLogic.skipTurn(); this.isBlackTurn = this.reversiLogic.isBlackTurn; }
<div><label for="y">縦軸</label><input type="number" name="axis_y" min="1" max={constants.MAX_HEIGHT} id="y"></div> <div><label for="x">横軸</label><input type="number" name="axis_x" min="1" max={constants.MAX_WIDTH} id="x"></div> <!-- ここから書き換え --> <div style="text-align: center;"> <button type="button" onclick={handleSkip}>手番をスキップ</button> <button type="button" onclick={handleStonePut}>石を置く</button> </div>
ここまで行けば、だいぶゲームらしくなったのでは?
ステータス表示
黒石と白石の数を取得できるようにしましょうか。
といっても、盤面の黒/白を数えるだけですが。
logic.js
にこんな getter を追加し
/** * 盤面の状態を返す */ get summary() { let white = 0; let black = 0; for (let y = 0; y < BOARD_HEIGHT; y++) { for (let x = 0; x < BOARD_WIDTH; x++) { const col = this.board[y][x]; if (col === BOARD_BLACK) { black++; } if (col === BOARD_WHITE) { white++; } } } return { black, white }; }
内容を app.js
で受け取りましょう。
reversiLogic = new ReversiLogic(); errorMessage = null; @track board = this.reversiLogic.board; @track isBlackTurn = this.reversiLogic.isBlackTurn; @track summary = this.reversiLogic.summary; // 追加 get summaryString() { // 追加 return `(黒: ${this.summary.black}, 白: ${this.summary.white})`; } /* 中略 */ handleStonePut() { try { const xValue = this.template.querySelector('input[name="axis_x"]').value; const yValue = this.template.querySelector('input[name="axis_y"]').value; this.errorMessage = this.reversiLogic.putStone(xValue - 1, yValue - 1, this.isBlackTurn); this.isBlackTurn = this.reversiLogic.isBlackTurn; this.summary = this.reversiLogic.summary; // 追加 } catch (error) { console.log(error.message); } }
そしたら、それを画面にも表示します。
<div style="text-align: center;"> <template if:true={isBlackTurn}><span>黒のターンです!</span></template> <template if:false={isBlackTurn}><span>白のターンです!</span></template> <span>{summaryString}</span> </div>
すると最終的にこんな感じになります。
ここまでのコードまとめ
logic.js
const BOARD_WIDTH = 8; const BOARD_HEIGHT = 8; const BOARD_EMPTY = 0; const BOARD_BLACK = 1; const BOARD_WHITE = 2; const Constants = { BOARD_WIDTH, BOARD_HEIGHT, BOARD_EMPTY, BOARD_BLACK, BOARD_WHITE }; class Reversi { board = []; isBlackTurn = true; constructor() { this.initBoard(); } /** * 盤面の状態を返す */ get summary() { let white = 0; let black = 0; for (let y = 0; y < BOARD_HEIGHT; y++) { for (let x = 0; x < BOARD_WIDTH; x++) { const col = this.board[y][x]; if (col === BOARD_BLACK) { black++; } if (col === BOARD_WHITE) { white++; } } } return { black, white }; } /** * 手番をスキップする */ skipTurn() { this.isBlackTurn = !this.isBlackTurn; } /** * 石を置く座標、それが黒石かどうか * @param {number} x * @param {number} y * @param {boolean} isBlack * @returns error message */ putStone(x, y, isBlack) { if (this.board.at(y)?.at(x) === null) { return 'そんな所に石は置けない!'; } if (this.board[y][x] !== BOARD_EMPTY) { return 'そこには既に石があります。'; } const vectors = [ [0, 1], [0, -1], // 下, 上 [1, 1], [1, -1], // 右下, 右上 [-1, 1], [-1, -1], // 左下, 左上 [1, 0], [-1, 0] // 右, 左 ]; // 置く石の色 const putStone = isBlack ? BOARD_BLACK : BOARD_WHITE; // 石を配置 this.board[y][x] = putStone; // 石を反転操作(全方向) let revStones = 0; vectors.forEach(v => { revStones += this.flipStone(x, y, v[0], v[1], putStone, 0); }); // 裏返せる石が無いなら、石は置けない if (revStones === 0) { this.board[y][x] = BOARD_EMPTY; return '裏返せる石が無いため、配置できません。'; } // ターン変更 this.isBlackTurn = !isBlack; return null; } flipStone(currentX, currentY, vX, vY, putColor, depth) { let result = 0; if (depth === 0) { // 起点座標は評価しない(putColor 置いた座標が putColor と一致するのは自明なので) // 次の座標をチェックする result = this.flipStone(currentX + vX, currentY + vY, vX, vY, putColor, depth + 1); } else { // それ以外は評価する // 現在の座標のマスを取得 const current = this.board.at(currentY)?.at(currentX); // 画面端 → 返す石はない if (current === null) { return 0; } // 空白が出現 → 返す石はない if (current === BOARD_EMPTY) { return 0; } // 同じ色の石がある if (current === putColor) { return depth - 1; } // 上記どれでもない → 敵対色の石がある(もしくは起点である) // 次の座標をチェックする result = this.flipStone(currentX + vX, currentY + vY, vX, vY, putColor, depth + 1); if (result > 0) { // 返す石がある → 現在の座標の石を置き換えて return this.board[currentY][currentX] = putColor; } } return result; } initBoard() { // シンプルにボードの高さ、幅で二次元配列を作る const root = []; for (let y=0; y<BOARD_HEIGHT; y++) { const row = []; for (let x=0; x<BOARD_WIDTH; x++) { row.push(BOARD_EMPTY); } root.push(row); } // 真ん中に初期配置の 白/黒 を置く const X_CENTER = BOARD_WIDTH / 2; const Y_CENTER = BOARD_HEIGHT / 2; // 配列は 0 から開始なので、4 は画面左から 5 マス目 root[X_CENTER - 1][Y_CENTER - 1] = BOARD_BLACK; root[X_CENTER][Y_CENTER - 1] = BOARD_WHITE; root[X_CENTER - 1][Y_CENTER] = BOARD_WHITE; root[X_CENTER][Y_CENTER] = BOARD_BLACK; this.board = root; } } export { Constants }; export default Reversi;
app.css
main { margin: 30px; display: flex; flex-direction: column; align-items: center; }
app.js
import { LightningElement, track } from 'lwc'; import ReversiLogic from 'reversi/logic'; import { Constants } from 'reversi/logic'; export default class HelloWorldApp extends LightningElement { reversiLogic = new ReversiLogic(); errorMessage = null; @track board = this.reversiLogic.board; @track isBlackTurn = this.reversiLogic.isBlackTurn; @track summary = this.reversiLogic.summary; constants = { MAX_WIDTH: Constants.BOARD_WIDTH, MAX_HEIGHT: Constants.BOARD_HEIGHT } get summaryString() { return `(黒: ${this.summary.black}, 白: ${this.summary.white})`; } get boardRows() { const rowToStr = (rowArr) => { return rowArr.map(n => { return n === 0 ? ' ' : (n === 1 ? '●' : '○'); }).join(' / '); }; let key = 0; return this.board.map(rowToStr).map(v => { return { key: key++, value: v }; }); } handleSkip() { this.reversiLogic.skipTurn(); this.isBlackTurn = this.reversiLogic.isBlackTurn; } handleStonePut() { try { const xValue = this.template.querySelector('input[name="axis_x"]').value; const yValue = this.template.querySelector('input[name="axis_y"]').value; this.errorMessage = this.reversiLogic.putStone(xValue - 1, yValue - 1, this.isBlackTurn); this.isBlackTurn = this.reversiLogic.isBlackTurn; this.summary = this.reversiLogic.summary; } catch (error) { console.log(error.message); } } }
<template> <main> <div style="display: inline-block; border: 1px solid #000;"> <template for:each={boardRows} for:item="row"> <div key={row.key}>{row.value}</div> </template> </div> <template if:true={errorMessage}> <div style="background-color: red; padding: .25rem; text-align: center;">{errorMessage}</div> </template> <div style="text-align: center;"> <template if:true={isBlackTurn}><span>黒のターンです!</span></template> <template if:false={isBlackTurn}><span>白のターンです!</span></template> <span>{summaryString}</span> </div> <div><label for="y">縦軸</label><input type="number" name="axis_y" min="1" max={constants.MAX_HEIGHT} id="y"></div> <div><label for="x">横軸</label><input type="number" name="axis_x" min="1" max={constants.MAX_WIDTH} id="x"></div> <div style="text-align: center;"> <button type="button" onclick={handleSkip}>手番をスキップ</button> <button type="button" onclick={handleStonePut}>石を置く</button> </div> </main> </template>
Good luck!
*1:厳密にはもっと複雑だったり、ラップされてたりとか色々しますが