はじめに
こんにちは。ココネでビリングシステムエンジニアをしていますnk60です。
自分の日々の業務は、主に会社の決済のサーバサイドの開発に携わっているのですが、ある日業務内で少しReactに触れることがありました。
ココネはゲーム会社なのですが、自分はあまりゲームの具体的な制作には携わっていません。
Reactを触った経験もほとんどなかったので、少し勉強しておこうと思いReactのチュートリアルを見てみると、内容が三目並べでした!
Reactの考え方やプログラムの書き方、三目並べを作るまでの過程などをみて面白いなと思いました。
ゲームを作ったことはほとんどないのですが、チュートリアルを参考に、自分なりに考えて将棋のゲームを作ってみました。
プロジェクトの作成
公式チュートリアルと同様にプロジェクトを作成します。
npx create-react-app my-app
cd my-app
cd src
# If you're using a Mac or Linux:
rm -f *
# Or, if you're on Windows:
del *
# Then, switch back to the project folder
cd ..
ソースコードの配置
作成した将棋ゲームのソースコードは以下になります。
src/index.jsがメインの画面を作る部分です。
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import imgKing from "./img/玉.png";
import imgRook from "./img/飛.png";
import imgBishop from "./img/角.png";
import imgGoldGeneral from "./img/金.png";
import imgSilverGeneral from "./img/銀.png";
import imgKnight from "./img/桂.png";
import imgLance from "./img/香.png";
import imgPawn from "./img/歩.png";
import imgPromotedRook from "./img/竜.png";
import imgPromotedBishop from "./img/馬.png";
import imgPromotedSilverGeneral from "./img/成銀.png";
import imgPromotedKnight from "./img/成桂.png";
import imgPromotedLance from "./img/成香.png";
import imgPromotedPawn from "./img/と.png";
import { BoardInfo, Selection } from './components/BoardInfo';
const imgByName = {
"玉": imgKing,
"飛": imgRook,
"角": imgBishop,
"金": imgGoldGeneral,
"銀": imgSilverGeneral,
"桂": imgKnight,
"香": imgLance,
"歩": imgPawn,
"竜": imgPromotedRook,
"馬": imgPromotedBishop,
"成銀": imgPromotedSilverGeneral,
"成桂": imgPromotedKnight,
"成香": imgPromotedLance,
"と": imgPromotedPawn
};
function Square(props) {
return (
<button id={props.selectInfo} className="square" onClick={props.onClick} >
<img id={props.piece.owner} src={imgByName[props.piece.name]} alt="" />
<p>{(props.num >= 2) && props.num}</p>
</button>
);
}
class Board extends React.Component {
renderSquare(i, j) {
return (
<Square
key={j}
piece={this.props.board[i][j]}
selectInfo={this.props.boardSelectInfo[i][j]}
onClick={() => this.props.onClick(i, j)}
/>
);
}
render() {
return (
<div>
{
Array(9).fill(0).map((_, i) => {
return (
<div className="board-row" key={i}>
{
Array(9).fill(0).map((_, j) => {
return (
this.renderSquare(i, j)
)
})
}
</div>
)
})
}
</div>
);
}
}
class PieceStand extends React.Component {
renderSquare(i) {
return (
<Square
key={i}
piece={this.props.pieceStand[i]}
num={this.props.pieceStandNum[this.props.pieceStand[i].name]}
selectInfo={this.props.pieceStandSelectInfo[i]}
onClick={() => this.props.onClick(i)}
/>
);
}
render() {
return (
<div className="board-row">
{
Array(9).fill(0).map((_, i) => {
return (
this.renderSquare(i)
)
})
}
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
boardInfo: new BoardInfo()
};
}
canselSelection() {
const nextBoardInfo = this.state.boardInfo;
if (nextBoardInfo.selection.isNow) {
nextBoardInfo.selection.isNow = false;
} else {
nextBoardInfo.selection = new Selection();
}
this.setState({
boardInfo: nextBoardInfo
});
}
boardClick(i, j) {
this.state.boardInfo.boardClick(i, j);
}
pieceStandClick(piece) {
this.state.boardInfo.pieceStandClick(piece);
}
render() {
return (
<div className="game" onClick={() => this.canselSelection()}>
<div className="game-board">
<PieceStand
pieceStand={this.state.boardInfo.pieceStand["後手"]}
pieceStandNum={this.state.boardInfo.pieceStandNum["後手"]}
pieceStandSelectInfo={this.state.boardInfo.selection.pieceStandSelectInfo["後手"]}
onClick={(i) => this.pieceStandClick(this.state.boardInfo.pieceStand["後手"][i])}
/>
<br />
<Board
board={this.state.boardInfo.board}
boardSelectInfo={this.state.boardInfo.selection.boardSelectInfo}
onClick={(i, j) => this.boardClick(i, j)}
/>
<br />
<PieceStand
pieceStand={this.state.boardInfo.pieceStand["先手"]}
pieceStandNum={this.state.boardInfo.pieceStandNum["先手"]}
pieceStandSelectInfo={this.state.boardInfo.selection.pieceStandSelectInfo["先手"]}
onClick={(i) => this.pieceStandClick(this.state.boardInfo.pieceStand["先手"][i])}
/>
</div>
</div>
);
}
}
// ========================================
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<Game />);
盤面の情報はcomponents/BoardInfo.jsにまとめています。
constructorで初期画面を生成しています。
import { Piece, Blank, King, Rook, Bishop, GoldGeneral, SilverGeneral, Knight, Lance, Pawn } from './Pieces';
class BoardInfo {
constructor() {
this.turn = "先手";
this.board = [[new Lance("後手"), new Knight("後手"), new SilverGeneral("後手"), new GoldGeneral("後手"), new King("後手"), new GoldGeneral("後手"), new SilverGeneral("後手"), new Knight("後手"), new Lance("後手")],
[new Blank(), new Rook("後手"), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Bishop("後手"), new Blank()],
[new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手"), new Pawn("後手")],
[new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank()],
[new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank()],
[new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank()],
[new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手"), new Pawn("先手")],
[new Blank(), new Bishop("先手"), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Rook("先手"), new Blank()],
[new Lance("先手"), new Knight("先手"), new SilverGeneral("先手"), new GoldGeneral("先手"), new King("先手"), new GoldGeneral("先手"), new SilverGeneral("先手"), new Knight("先手"), new Lance("先手")]
];
this.selection = new Selection();
this.pieceStandNum = {
"先手": { "歩": 0, "香": 0, "桂": 0, "銀": 0, "金": 0, "角": 0, "飛": 0 },
"後手": { "歩": 0, "香": 0, "桂": 0, "銀": 0, "金": 0, "角": 0, "飛": 0 }
};
this.pieceStand = {
"先手": [new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank()],
"後手": [new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank(), new Blank()]
};
}
boardClick(i, j) {
if (this.selection.state) {
if (this.selection.boardSelectInfo[i][j] !== "配置可能") {
return;
}
let myPiece;
if (this.selection.pieceStandPiece.name) {
myPiece = this.selection.pieceStandPiece;
this.pieceStandNum[this.turn][myPiece.name] -= 1;
this.makePieceStand();
} else {
myPiece = this.board[this.selection.before_i][this.selection.before_j];
this.board[this.selection.before_i][this.selection.before_j] = new Blank();
let yourPiece = this.board[i][j];
if (yourPiece.name) {
if (yourPiece.getPiece()) {
yourPiece = yourPiece.getPiece();
}
this.pieceStandNum[myPiece.owner][yourPiece.name] += 1;
this.makePieceStand();
}
if (this.existCanMove(i, j, myPiece)) {
myPiece = this.checkPromote(myPiece, i, this.selection.before_i);
} else {
myPiece = myPiece.getPromotedPiece();
}
}
this.board[i][j] = myPiece;
this.turn = this.turn === "先手" ? "後手" : "先手";
} else {
if (this.turn !== this.board[i][j].owner) {
return;
}
this.selection.isNow = true;
this.selection.state = true;
this.selection.before_i = i;
this.selection.before_j = j;
this.selection.boardSelectInfo = JSON.parse(JSON.stringify((new Array(9)).fill((new Array(9)).fill("未選択"))));
this.selection.pieceStandSelectInfo = {
"先手": Array(9).fill("未選択"),
"後手": Array(9).fill("未選択")
};
this.selection.boardSelectInfo[i][j] = "選択状態";
this.checkCanPutBoard(i, j);
}
}
existCanMove(i, j, piece) {
for (let l = 0; l < piece.dx.length; l++) {
let y = i;
let x = j;
y += this.turn === "先手" ? piece.dy[l] : -piece.dy[l];
x += this.turn === "先手" ? piece.dx[l] : -piece.dx[l];
if (0 <= y && y <= 8 && 0 <= x && x <= 8) {
return true;
}
}
return false;
}
checkPromote(piece, i, before_i) {
if (!piece.getPromotedPiece()) {
return piece;
}
const promoteAreaMinY = piece.owner === "先手" ? 0 : 6;
const promoteAreaMaxY = piece.owner === "先手" ? 2 : 8;
if ((promoteAreaMinY <= i && i <= promoteAreaMaxY) || (promoteAreaMinY <= before_i && before_i <= promoteAreaMaxY)) {
if (window.confirm('成りますか?')) {
return piece.getPromotedPiece()
}
}
return piece;
}
checkCanPutBoard(i, j) {
const piece = this.board[i][j];
for (let l = 0; l < piece.dx.length; l++) {
let y = i;
let x = j;
for (let _ = 0; _ < piece.dk[l]; _++) {
y += this.turn === "先手" ? piece.dy[l] : -piece.dy[l];
x += this.turn === "先手" ? piece.dx[l] : -piece.dx[l];
if (y < 0 || y > 8 || x < 0 || x > 8 || this.board[y][x].owner === piece.owner) {
break;
}
this.selection.boardSelectInfo[y][x] = "配置可能";
if (!this.board[y][x].owner) {
continue;
}
break;
}
}
}
pieceStandClick(piece) {
if (this.selection.state || this.turn !== piece.owner) {
return;
}
this.selection.isNow = true;
this.selection.state = true;
this.selection.boardSelectInfo = JSON.parse(JSON.stringify((new Array(9)).fill((new Array(9)).fill("未選択"))));
this.selection.pieceStandPiece = piece;
this.selection.pieceStandSelectInfo = {
"先手": Array(9).fill("未選択"),
"後手": Array(9).fill("未選択")
};
const i = this.pieceStand[piece.owner].findIndex(p => p.name === piece.name);
this.selection.pieceStandSelectInfo[this.turn][i] = "選択状態";
this.checkCanPutPieceStand(piece);
}
makePieceStand() {
let myPieceStand = [];
const myPieceStandNum = this.pieceStandNum[this.turn];
for (let name in myPieceStandNum) {
if (myPieceStandNum[name] > 0) {
myPieceStand.push(Piece.getPieceByName(name, this.turn));
}
}
while (myPieceStand.length < 9) {
myPieceStand.push(new Blank());
}
this.pieceStand[this.turn] = myPieceStand;
}
checkCanPutPieceStand(piece) {
let pawnColMemo = Array(9).fill(true);
if (piece.name === "歩") {
for (let i = 0; i < 9; i++) {
for (let j = 0; j < 9; j++) {
if (this.board[i][j].name === "歩" && this.board[i][j].owner === piece.owner) {
pawnColMemo[j] = false;
}
}
}
}
for (let i = 0; i < 9; i++) {
for (let j = 0; j < 9; j++) {
if (!this.board[i][j].owner && this.existCanMove(i, j, piece) && pawnColMemo[j]) {
this.selection.boardSelectInfo[i][j] = "配置可能";
}
}
}
}
}
class Selection {
boardSelectInfo = JSON.parse(JSON.stringify((new Array(9)).fill((new Array(9)).fill(""))));
isNow = false;
state = false;
before_i = null;
before_j = null;
pieceStandSelectInfo = {
"先手": Array(9).fill("持駒"),
"後手": Array(9).fill("持駒")
};
pieceStandPiece = new Blank();
}
export { BoardInfo, Selection };
各駒の動き方や名前、成った後の駒、成る前の駒などをcomponents/Pieces.jsでまとめています。
各駒がPieceクラスを継承しており、constructorの引数でowner(先手 or 後手)を渡します。
class Piece {
constructor(owner) {
this.owner = owner;
}
getPiece() {
return null;
}
getPromotedPiece() {
return null;
}
static getPieceByName(name, owner) {
switch (name) {
case "飛":
return new Rook(owner);
case "角":
return new Bishop(owner);
case "金":
return new GoldGeneral(owner);
case "銀":
return new SilverGeneral(owner);
case "桂":
return new Knight(owner);
case "香":
return new Lance(owner);
case "歩":
return new Pawn(owner);
default:
return null;
}
}
}
class Blank extends Piece {
}
class King extends Piece {
name = "玉";
dx = [-1, -1, -1, 0, 1, 1, 1, 0];
dy = [-1, 0, 1, 1, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1, 1, 1];
}
class Rook extends Piece {
name = "飛";
dx = [-1, 0, 1, 0];
dy = [0, 1, 0, -1];
dk = [10, 10, 10, 10];
getPromotedPiece() {
return new PromotedRook(this.owner);
}
}
class Bishop extends Piece {
name = "角";
dx = [-1, -1, 1, 1];
dy = [-1, 1, 1, -1];
dk = [10, 10, 10, 10];
getPromotedPiece() {
return new PromotedBishop(this.owner);
}
}
class GoldGeneral extends Piece {
name = "金";
dx = [-1, -1, 0, 1, 1, 0];
dy = [-1, 0, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1];
}
class SilverGeneral extends Piece {
name = "銀";
dx = [-1, -1, 1, 1, 0];
dy = [-1, 1, 1, -1, -1];
dk = [1, 1, 1, 1, 1];
getPromotedPiece() {
return new PromotedSilverGeneral(this.owner);
}
}
class Knight extends Piece {
name = "桂";
dx = [-1, 1];
dy = [-2, -2];
dk = [1, 1];
getPromotedPiece() {
return new PromotedKnight(this.owner);
}
}
class Lance extends Piece {
name = "香";
dx = [0];
dy = [-1];
dk = [10];
getPromotedPiece() {
return new PromotedLance(this.owner);
}
}
class Pawn extends Piece {
name = "歩";
dx = [0];
dy = [-1];
dk = [1];
getPromotedPiece() {
return new PromotedPawn(this.owner);
}
}
class PromotedRook extends Piece {
name = "竜";
dx = [-1, 0, 1, 0, -1, -1, 1, 1];
dy = [0, 1, 0, -1, -1, 1, 1, -1];
dk = [10, 10, 10, 10, 1, 1, 1, 1];
getPiece() {
return new Rook(this.owner);
}
}
class PromotedBishop extends Piece {
name = "馬";
dx = [-1, -1, 1, 1, -1, 0, 1, 0];
dy = [-1, 1, 1, -1, 0, 1, 0, -1];
dk = [10, 10, 10, 10, 1, 1, 1, 1];
getPiece() {
return new Bishop(this.owner);
}
}
class PromotedSilverGeneral extends Piece {
name = "成銀";
dx = [-1, -1, 0, 1, 1, 0];
dy = [-1, 0, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1];
getPiece() {
return new SilverGeneral(this.owner);
}
}
class PromotedKnight extends Piece {
name = "成桂";
dx = [-1, -1, 0, 1, 1, 0];
dy = [-1, 0, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1];
getPiece() {
return new Knight(this.owner);
}
}
class PromotedLance extends Piece {
name = "成香";
dx = [-1, -1, 0, 1, 1, 0];
dy = [-1, 0, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1];
getPiece() {
return new Lance(this.owner);
}
}
class PromotedPawn extends Piece {
name = "と";
dx = [-1, -1, 0, 1, 1, 0];
dy = [-1, 0, 1, 0, -1, -1];
dk = [1, 1, 1, 1, 1, 1];
getPiece() {
return new Pawn(this.owner);
}
}
export { Piece, Blank, King, Rook, Bishop, GoldGeneral, SilverGeneral, Knight, Lance, Pawn};
index.cssで見た目を将棋盤に整えます。
後手の駒か、持ち駒か、選択状態にあるか、配置可能なマス目かなどで色を変えています。
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.square {
position: relative;
background: #FFCC00;
border: 1px solid #999;
float: left;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
width: 34px;
}
.square p {
margin-top: 1px;
color: yellow;
position: absolute;
top: 0;
left: 0;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
#未選択.square {
background: rgba(0, 0, 0, .5);
}
#選択状態.square {
background: red;
}
#配置可能.square {
background: yellow;
}
#持駒.square {
background: darkgoldenrod;
}
img {
width: 28px;
}
img#後手 {
transform: scale(-1, -1);
}
ソース配置
srcフォルダ内にcomponentsディレクトリとimgディレクトリが含まれ、imgディレクトリに各駒の画像を入れています。
この画像はフリー素材の駒の画像をサイズが同じに成るように揃え、背景を透明にしたものです。
完成後の動作
上記ソースコードを配置すると以下のように動作します。
移動可能なマス目の表示
将棋のゲームを作ろうと思った際に最初にぶつかる壁は、駒の種類と移動のパターンの多さだと思います。
歩は前に一回進めますが、角は斜めにずっと進めます。
class Pawn extends Piece {
name = "歩";
dx = [0];
dy = [-1];
dk = [1];
getPromotedPiece() {
return new PromotedPawn(this.owner);
}
}
class Bishop extends Piece {
name = "角";
dx = [-1, -1, 1, 1];
dy = [-1, 1, 1, -1];
dk = [10, 10, 10, 10];
getPromotedPiece() {
return new PromotedBishop(this.owner);
}
}
dxの長さ分for文を回し、現在の座標からdx, dyぶん進みそれをdk回繰り返します。
(二次元配列と同じなので、yは下向きが正)
駒がそれ以上進めない条件は以下です。
1.盤の外に出たらそれ以上進めない。
2.自分の駒があるときそのマスには進めず、それ以上進めない。
3.相手の駒がある時、相手の駒をとりそのマスに進める。それ以降は進めない。
これらを用いて、移動可能なマスは以下のように表せます。
checkCanPutBoard(i, j) {
const piece = this.board[i][j];
for (let l = 0; l < piece.dx.length; l++) {
let y = i;
let x = j;
for (let _ = 0; _ < piece.dk[l]; _++) {
y += this.turn === "先手" ? piece.dy[l] : -piece.dy[l];
x += this.turn === "先手" ? piece.dx[l] : -piece.dx[l];
if (y < 0 || y > 8 || x < 0 || x > 8 || this.board[y][x].owner === piece.owner) {
break;
}
this.selection.boardSelectInfo[y][x] = "配置可能";
if (!this.board[y][x].owner) {
continue;
}
break;
}
}
}
全体的な処理の流れ
このゲームには3つのイベント処理が入ってます。
1.canselSelection
2.pieceStandClick
3.boardClick
render() {
return (
<div className="game" onClick={() => this.canselSelection()}>
<div className="game-board">
<PieceStand
pieceStand={this.state.boardInfo.pieceStand["後手"]}
pieceStandNum={this.state.boardInfo.pieceStandNum["後手"]}
pieceStandSelectInfo={this.state.boardInfo.selection.pieceStandSelectInfo["後手"]}
onClick={(i) => this.pieceStandClick(this.state.boardInfo.pieceStand["後手"][i])}
/>
<br />
<Board
board={this.state.boardInfo.board}
boardSelectInfo={this.state.boardInfo.selection.boardSelectInfo}
onClick={(i, j) => this.boardClick(i, j)}
/>
<br />
<PieceStand
pieceStand={this.state.boardInfo.pieceStand["先手"]}
pieceStandNum={this.state.boardInfo.pieceStandNum["先手"]}
pieceStandSelectInfo={this.state.boardInfo.selection.pieceStandSelectInfo["先手"]}
onClick={(i) => this.pieceStandClick(this.state.boardInfo.pieceStand["先手"][i])}
/>
</div>
</div>
);
}
ReactのonClickイベントは、小要素から親要素に伝播していきます。
一番親要素のclassName="game"のcanselSelectionは、盤面をクリックした時も、持ち駒をクリックした時も、またそれ以外の場所をクリックした時も呼ばれることになります。
canselSelection() {
const nextBoardInfo = this.state.boardInfo;
if (nextBoardInfo.selection.isNow) {
nextBoardInfo.selection.isNow = false;
} else {
nextBoardInfo.selection = new Selection();
}
this.setState({
boardInfo: nextBoardInfo
});
}
boardClick(i, j) {
this.state.boardInfo.boardClick(i, j);
}
pieceStandClick(piece) {
this.state.boardInfo.pieceStandClick(piece);
}
これは、盤面の駒、または持ち駒の駒を選択したときに盤面をグレーにしていますが、次どこかをクリックした時、必ず選択した状態を初期化し、色を元の状態に戻すためです。(nextBoardInfo.selection = new Selection();)
boardClick(盤面をクリック)、pieceStandClick(持ち駒をクリック)した時は、this.state.boardInfoの中身が次の状態に書き換えられますが、これはまだstateにsetされていません。直後に呼ばれるcanselSelectionのsetStateによってstate内のboardInfoの情報がnextBoardInfoの情報に書き換えられます。
最後に
ここまで読んでいただきありがとうございました。
Reactで作る将棋ゲームについて書かせていただきましたが、細かいルールなどに関しては実装できていない部分も多いと思います。
Reactチュートリアルと同じように棋譜を残すようにしたり、詰みの判定を入れるなど、まだできることは多くあると思います。
自分もReactの知識はまだまだ浅い部分があるので、改善できるところも多くあるかもしれないです。
少しでも興味を持っていただいた部分や、参考になった部分があったと思っていただけなら幸いです。
今日はクリスマスですがいかがお過ごしでしょうか
来年も良いお年を!