JavaScript (+LWC)のチュートリアル: リバーシを作る(3)
JavaScript (+LWC)のチュートリアル: リバーシを作る(3)
概要
LWC らしくコンポーネント化していきます。
前提知識
前回までの知識
実装
ボードの適用
初期状態はこんな感じ
見ればわかりますが、リバーシのボードは、単純に 1 マス 1 マスのブロックの集まりの繰り返しですので、単純にそれを1コンポーネントにしてみます。
- src
- modules
- reversi
- column (新規作成)
- column.css
- column.js
- column.html
- column (新規作成)
- reversi
- modules
これを作成します。
基本的に空のテキストファイルで用意します。
1 マスの状況を見ると 「何も置かれていない」「白石」「黒石」 という状態を持ちますので、まずはそれを表示できるようにしましょう。
column.js
をまずは書いていきます。
import { api, LightningElement } from 'lwc'; import { Constants } from 'reversi/logic'; export default class Column extends LightningElement { @api status = null; get isBlank() { return this.status === Constants.BOARD_EMPTY; } get isWhite() { return this.status === Constants.BOARD_WHITE; } get isBlack() { return this.status === Constants.BOARD_BLACK; } }
@api
と言うのは、このクラスの外側、もっというとこのコンポーネントを使う側からみたインターフェースです。
単純にクラス変数に対して @api
を使うと、親コンポーネントからは属性値として設定できます。
また、わざわざ getter
を用意しているのは、LWC のテンプレート内で Boolean 計算出来ないからです。
結構テンプレートで Boolean
計算はできるフレームワークは多いのですが、まぁこういうこともあると。
次に画面( column.html
)を作成します。
<template> <div class="backgroud"> <template if:true={isBlank}> </template> <template if:true={isWhite}> <div class="circle white"> </div> </template> <template if:true={isBlack}> <div class="circle black"> </div> </template> </div> </template>
このままだとただただ真っ白な画面が出るだけなので、スタイル(column.css
)も設定します。
.backgroud { display: inline-block; width: 40px; height: 40px; margin: 0; background-color: darkgreen; border: 1px solid midnightblue; } .circle { width: 38px; height: 38px; border-radius: 50%; } .white { background-color: white; border: 1px solid gray; } .black { background-color: #444; border: 1px solid black; }
そしたらこれをボード表示 app
に適用していきましょう。
ボードは単純に数字の二次元配列ですが、LWC のテンプレートでループするには各ループ単位で 「key」 が必要なので、ひと手間かけます。
get renderRows() { let rowId = 0; return this.board.map(row => { let colId = 0; const renderRow = row.map(col => { return { key: colId++, value: col }; }); return { key: rowId++, value: renderRow } }) }
では画面を修正しましょう。
<!-- <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 for:each={renderRows} for:item="row"> <div key={row.key} style="height: 40px;"> <template for:each={row.value} for:item="col"> <reversi-column key={col.key} status={col.value}></reversi-column> </template> </div> </template>
すると
石を直接置けるようにしよう
入力がいつまでも数字入力なんて直感的ではありませんので、石を置けるように変更していきます。
まずはマウスカーソルをクリック可能状態で定義 column.css
.click_able { cursor: pointer; display: inline-block; width: 100%; height: 100%; background-color: transparent; }
そして、画面側 (column.html
) で
<template> <div class="backgroud"> <template if:true={isBlank}> <div class="click_able"> </div> </template> <template if:true={isWhite}> <div class="circle white"> </div> </template> <template if:true={isBlack}> <div class="circle black"> </div> </template> </div> </template>
やってみればわかりますが、この時点で空白マスにマウスカーソルを持っていくと、クリックできそうな見た目になります(まだクリックしても何もできませんが)。
ではついで、クリックしたときに通知を飛ばすようにします。
LWC の親タグ、子タグのやり取りは以下のように設定します。
- 親画面 → 子コンポーネント : 子コンポーネント側の
@api status
の値に対して、親コンポーネント側から属性<reversi-column status={col.value}></reversi-column>
で値を渡します。 - 子コンポーネント → 親画面 : イベント発生で通知する。
これがLWCの原則的な親子関係のデータの受け渡しになります。
これに則り、クリック可能(中身がブランク)で、クリックされたらイベントで親コンポーネントに通知するよう修正します。
column.js
にて
handleClick() { if (!this.isBlank) { return; } this.dispatchEvent(new CustomEvent('putstone')); }
このイベントハンドラを画面 (column.html
) で設定します。
<template> <div class="backgroud" onclick={handleClick}> ...
親コンポーネントでもこれを適用していきましょう。
まずはイベントを受け取るハンドラから定義(app.js
)
handlePutStone(event) { console.log(event); console.log(event.target); }
そしたら、HTML (app.html
) 側を修正します。
このとき、どのマスをクリックしたのか解るように data-x
/ data-y
を設定します。
ちなみに data-xxxx
など data-
で始まる属性は、HTML 規約上好きな値を好きに設定して良い ことになってます。
<template for:each={renderRows} for:item="row"> <div key={row.key} style="height: 40px;"> <template for:each={row.value} for:item="col"> <reversi-column data-y={row.key} data-x={col.key} key={col.key} status={col.value} onputstone={handlePutStone}></reversi-column> </template> </div> </template>
ここまでで画面を表示し、Chrome/FireFox ならF12 キー(開発者コンソール)を表示します。
クリックすると…
この data
を読み取って石を置きましょう。
handlePutStone(event) { console.log(event); console.log(event.target); // 石を置こうとしたターゲット const target = event.target; // ターゲットから座標を取得 const y = parseInt(target.getAttribute('data-y')); const x = parseInt(target.getAttribute('data-x')); // 石を置きましょう this.errorMessage = this.reversiLogic.putStone(x, y, this.isBlackTurn); this.isBlackTurn = this.reversiLogic.isBlackTurn; this.summary = this.reversiLogic.summary; }
すると、
ここまで来たら、座標入力はいりませんね。消し方は…まぁコード削除だけなのでご自分でw
完成
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 renderRows() { let rowId = 0; return this.board.map(row => { let colId = 0; const renderRow = row.map(col => { return { key: colId++, value: col }; }); return { key: rowId++, value: renderRow } }) } handleSkip() { this.reversiLogic.skipTurn(); this.isBlackTurn = this.reversiLogic.isBlackTurn; } handlePutStone(event) { console.log(event); console.log(event.target); // 石を置こうとしたターゲット const target = event.target; // ターゲットから座標を取得 const y = parseInt(target.getAttribute('data-y')); const x = parseInt(target.getAttribute('data-x')); // 石を置きましょう this.errorMessage = this.reversiLogic.putStone(x, y, this.isBlackTurn); this.isBlackTurn = this.reversiLogic.isBlackTurn; this.summary = this.reversiLogic.summary; } }
app.html
<template> <main> <template for:each={renderRows} for:item="row"> <div key={row.key} style="height: 40px;"> <template for:each={row.value} for:item="col"> <reversi-column data-y={row.key} data-x={col.key} key={col.key} status={col.value} onputstone={handlePutStone}></reversi-column> </template> </div> </template> <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 style="text-align: center;"> <button type="button" onclick={handleSkip}>手番をスキップ</button> </div> </main> </template>
app.css
main { margin: 30px; display: flex; flex-direction: column; align-items: center; }
column.css
.backgroud { display: inline-block; width: 40px; height: 40px; margin: 0; background-color: darkgreen; border: 1px solid midnightblue; } .circle { width: 38px; height: 38px; border-radius: 50%; } .white { background-color: white; border: 1px solid gray; } .black { background-color: #444; border: 1px solid black; } .click_able { cursor: pointer; display: inline-block; width: 100%; height: 100%; background-color: transparent; }
column.html
<template> <div class="backgroud" onclick={handleClick}> <template if:true={isBlank}> <div class="click_able"> </div> </template> <template if:true={isWhite}> <div class="circle white"> </div> </template> <template if:true={isBlack}> <div class="circle black"> </div> </template> </div> </template>
column.js
import { api, LightningElement } from 'lwc'; import { Constants } from 'reversi/logic'; export default class Column extends LightningElement { @api status = null; get isBlank() { return this.status === Constants.BOARD_EMPTY; } get isWhite() { return this.status === Constants.BOARD_WHITE; } get isBlack() { return this.status === Constants.BOARD_BLACK; } handleClick() { if (!this.isBlank) { return; } this.dispatchEvent(new CustomEvent('putstone')); } }
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; // console.log(`flipStone(${currentX}, ${currentY}, ${vX}, ${vY}, ${putColor}, ${depth})`); // 画面外に出たら強制終了 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; } 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;