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;