技術をかじる猫

適当に気になった技術や言語、思ったこと考えた事など。

JavaScript (+LWC)のチュートリアル: リバーシを作る(3)

JavaScript (+LWC)のチュートリアル: リバーシを作る(3)

概要

LWC らしくコンポーネント化していきます。

前提知識

前回までの知識

実装

ボードの適用

初期状態はこんな感じ

見ればわかりますが、リバーシのボードは、単純に 1 マス 1 マスのブロックの集まりの繰り返しですので、単純にそれを1コンポーネントにしてみます。

  • src
    • modules
      • reversi
        • column (新規作成)
          • column.css
          • column.js
          • column.html

これを作成します。
基本的に空のテキストファイルで用意します。

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}>
            &nbsp;
        </template>
        <template if:true={isWhite}>
            <div class="circle white">&nbsp;</div>
        </template>
        <template if:true={isBlack}>
            <div class="circle black">&nbsp;</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">&nbsp;</div>
        </template>
        <template if:true={isWhite}>
            <div class="circle white">&nbsp;</div>
        </template>
        <template if:true={isBlack}>
            <div class="circle black">&nbsp;</div>
        </template>
    </div>
</template>

やってみればわかりますが、この時点で空白マスにマウスカーソルを持っていくと、クリックできそうな見た目になります(まだクリックしても何もできませんが)。

ではついで、クリックしたときに通知を飛ばすようにします。
LWC の親タグ、子タグのやり取りは以下のように設定します。

これが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">&nbsp;</div>
        </template>
        <template if:true={isWhite}>
            <div class="circle white">&nbsp;</div>
        </template>
        <template if:true={isBlack}>
            <div class="circle black">&nbsp;</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;