ロゴシュミグラム

Javascriptによるオセロの作り方
【オブジェクト指向編】その2

CSSで石を回転させる方法

これから作る石のHTMLは次のようになっています。

石のHTML

<div class="stone">
<div class="stone-face-1"></div>
<div class="stone-face-2" style="background-color: white;"></div>
</div>

石のdiv要素の中に、石の色を表すdiv要素が2つ入っています。この class="stone-face-1"class="stone-face-2" がついているものを石面1、石面2とよぶことにします。
初期状態では石面1が裏側、石面2が表側となっています。初期状態では裏側は見えないので色を付けていません。

この石を裏返すと次のようになります。

石のHTML

<div class="stone" style="transform: rotateY(180deg);">
<div class="stone-face-1" style="background-color: black;"></div>
<div class="stone-face-2" style="background-color: white;"></div>
</div>

まず、裏側となっていた石面1に background-color: black をつけることで黒色にしています。
そのあと、石のdiv要素に transform: rotateY(180deg) をつけることで180度回転させます。
こうすることで表側が石面1となるので、石が黒色に変化しました。

この石をもう1回、裏返すと次のようになります。

石のHTML

<div class="stone" style="transform: rotateY(360deg);">
<div class="stone-face-1" style="background-color: black;"></div>
<div class="stone-face-2" style="background-color: orange;"></div>
</div>

裏側となっていた石面2に background-color: orange をつけることでオレンジ色にしています。
さっきまで transform: rotateY(180deg) だった要素をさらに180度回転させるため、 transform: rotateY(360deg) をつけます。
こうすることで今度は表側が石面2となるので、石がオレンジ色に変わりました。

このようにしてCSSで石を回転させています。

石のCSSを追加する

styles.cssに次のものを書きくわえます。

styles.css

.stone {
position: relative;
width: 80%;
height: 80%;
transform-style: preserve-3d;
}
.stone-face-1,
.stone-face-2 {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
box-shadow: 1px 2px 2px 0px rgba(0, 0, 0, 0.4);
backface-visibility: hidden;
/* 光沢をつける */
background: radial-gradient(circle closest-side at 20% 20%, rgba(256, 256, 256, 0.3) 0%, transparent 100%);
}
.stone-face-1 {
/* 初期状態では裏側となる */
transform: rotateY(180deg);
}

stonetransform-style: preserve-3d をつけて、その子要素である stone-facebackface-visibility: hidden をつけています。
こうすることで、 stonetransform: rotateY(180deg) をつけるたとき、表と裏が回転しているようにみえます。

石となるStoneクラスの作成

Stoneクラスには次のようなプロパティやメソッドがあります。

名前説明
constructor(color)コンストラクターでColorオブジェクトを受け取る
colorColorオブジェクト
flippedCount裏返した回数
element石となるdiv要素
face1Element石面1となるdiv要素
face2Element石面2となるdiv要素
createElements()上記のdiv要素たちを作成する
flip(nextColor)裏側に新しい色を付けて、石を裏返す
remove()この石のdiv要素を除去する

Stone.jsに次のように書き込みます。

Stone.js

class Stone {
constructor(color) {
this.color = color;
this.flippedCount = 0;
// 石となるdiv要素たちを作成する
this.createElements();
// この色の石の数を1つ増やす
this.color.stoneCount++;
}
createElements () {
// ラッパーとなるdiv要素を作成する
this.element = document.createElement('div');
this.element.classList.add('stone');
this.element.style.transitionDuration = `${FLIP_DURATION_TIME}ms`;
// 石面1のdiv要素を作成する。初期状態ではこれが裏側となる
this.face1Element = document.createElement('div');
this.face1Element.classList.add('stone-face-1');
// 石面2のdiv要素を作成する。初期状態ではこれが表側となるため色付けしておく。
this.face2Element = document.createElement('div');
this.face2Element.classList.add('stone-face-2');
this.face2Element.style.backgroundColor = this.color.colorCode;
this.element.append(this.face1Element, this.face2Element);
}
flip (nextColor) {
// 現在、裏側となっている石面のdiv要素を取得する
// 裏返した回数が偶数回ならばface1Elementが裏側となっており、
// 奇数回であればface2Elementが裏側となっている
const backFaceElement = this.flippedCount % 2 === 0 ? this.face1Element : this.face2Element;
// 裏側の石面のdiv要素に色を付ける
backFaceElement.style.backgroundColor = nextColor.colorCode;
// 石を裏返す
this.flippedCount++;
this.element.style.transform = `rotateY(${this.flippedCount * 180}deg)`;
// 今まで表側であった色の石の数を1つ減らす
this.color.stoneCount--;
// 新たに表側となった色の石の数を1つ増やす
nextColor.stoneCount++;
this.color = nextColor;
}
remove () {
// 石のdiv要素を除去して、この色の石の数を減らす
this.element.remove();
this.color.stoneCount--;
}
}

マス目となるSquareクラスの作成

Squareクラスには次のようなプロパティやメソッドがあります。

名前説明
constructor(rowIndex, columnIndex)コンストラクターで行番号と列番号を受け取る
rowIndexこのマス目の行番号
columnIndexこのマス目の列番号
stoneStoneオブジェクト
sandwichStones現在のプレイヤーがこのマスに石を置いたら挟むことができる石の配列
isCorner角のマスが否か
elementマス目となるdiv要素
setNewStone(color)引数で受け取ったColorオブジェクト受け取り、その色の石を置く
setSandwichStones()挟める石の配列をセットする
isSelectable()このマス目に石を置くことができるか判別する
select()このマス目に石を置いたあと、ゲームを進行する
click()このマス目をクリックしたときの処理
removeStone()このマス目に石が置いてあれば、それを除去する

Square.jsに次のように書き込みます。

Square.js

class Square {
constructor(rowIndex, columnIndex) {
this.rowIndex = rowIndex;
this.columnIndex = columnIndex;
// 行番号と列番号をIDに持つdiv要素がすでに作成されているのでそれを取得する
this.element = document.getElementById(`index-${rowIndex}-${columnIndex}`);
this.element.classList.add('square');
this.element.addEventListener('click', this.click.bind(this));
this.stone = null;
this.sandwichStones = [];
// 角のマス目か否かを行番号と列番号から判別する
this.isCorner = rowIndex === 0 && columnIndex === 0
|| rowIndex === 0 && columnIndex === initialBoard[0].length - 1
|| rowIndex === initialBoard.length - 1 && columnIndex === 0
|| rowIndex === initialBoard.length - 1 && columnIndex === initialBoard[0].length - 1;
// 空のマス目の個数を一つ増やす
state.emptySquareCount++;
}
setNewStone (color) {
this.stone = new Stone(color);
// マス目のdiv要素に石のdiv要素を入れる
this.element.append(this.stone.element);
// 空のマス目の個数を一つ減らす
state.emptySquareCount--;
}
isSelectable () {
// このマス目に石が置いていない、かつ挟める石がある場合は石を置ける
return this.stone === null && this.sandwichStones.length !== 0;
}
select () {
this.setNewStone(state.currentPlayer.color);
state.sandwichedStones = this.sandwichStones;
// ゲームを進行する
game.update();
}
click () {
// 現在のプレイヤーがヒトで、かつ石を置くことができるマス目であれば石を置く
// こうすることで無関係なマス目をクリックしても何も起きないようにする
if (state.currentPlayer.isHuman() && this.isSelectable()) {
this.select();
}
}
setSandwichStones (sandwichStones) {
this.sandwichStones = sandwichStones;
// 現在のプレイヤーがヒトの場合、マス目の上に矢印を表示したりけしたりする
if (state.currentPlayer.isHuman()) {
if (this.isSelectable()) {
this.element.classList.add('selectable');
} else {
this.element.classList.remove('selectable');
}
}
}
removeStone () {
if (this.stone) {
// 石のdiv要素を除去する
this.stone.remove();
this.stone = null;
// 空のマス目の個数を一つ増やす
state.emptySquareCount++;
}
}
}

コンストラクター内で this.element.addEventListener('click', this.click.bind(this)); とすることで、このマス目をクリックしたらclickメソッドが実行されるようになります。

.bind(this) をつけないと、clickメソッド内の this はクリックしたdiv要素となるため、実際にクリックしたらエラーが生じます。

.bind(this) をつけることによりclickメソッド内の this がSquareインスタンスとなり正常に動作するようになります。

マス目のCSSの追加

次のようにstyles.cssに書きくわえます。

styles.css

.square {
display: flex;
justify-content: center;
align-items: center;
border: 2px outset #000;
/* マス目の背景色に少しグラデーションをかける */
background: radial-gradient(circle farthest-side at 100% 100%, forestgreen 0%, darkgreen 100%);
}
/* 回転しながらとび跳ねるアニメーション */
@keyframes hopping {
0% {
translate: 0 -14px;
transform: rotateY(0deg);
}
100% {
translate: 0 0;
transform: rotateY(180deg);
}
}
.selectable::after {
content: '▼';
color: red;
font-size: 20px;
animation-name: hopping;
animation-duration: 700ms;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: ease-in-out;
}

これでマス目のclass属性に selectable を追加すると赤い矢印が飛び跳ねるアニメーションが追加されます。

どの石を挟めるのかをチェックする方法

たとえば次のような盤面のとき、 :1 :0 のマス目に白い石を置くとしたらどの石を挟めるでしょうか?

挟める石チェック

上下左右、斜めの八方向をそれぞれチェックしていきます。それぞれチェックし始めるときは、挟める石を入れておく配列 sandwichStones を用意しておきます。

左上方向のチェック

まず、左上からチェックしていきます。左上のマス目に移動するには、行番号に-1を、列番号に-1をに加えていきます。そうすると左上のマス目は :0 :-1 となりますが、そこにマス目はないので挟める石も存在しません。

上方向のチェック

次に、真上をチェックしていきます。行番号に-1を、列番号に0を加えた :0 :0 の場所にはマス目はありますが石が置いてありません。よって挟める石もありません。

右上方向のチェック

右上をチェックしていきます。行番号に-1を、列番号に1を加えた :0 :1 のマス目には黒い石が置いてあります。自分以外の石にに出合ったら、その石を sandwichStones に入れておきます。
さらに行番号に-1を、列番号に1を加えた :-1 :2 の場所にはマス目がないので挟める石もありません。

左方向のチェック

左上と同じくマス目がないので挟める石も存在しません。

右方向のチェック

行番号に0を、列番号に1を加えた :1 :1 の場所には黒い石が置いてるので sandwichStones に入れておきます。
さらに行番号に0を、列番号に1を加えた :1 :2 のマス目には白い石があります。自分と同じ色の石に出合った場合、 sandwichStones に入っている石が挟める石となります。

左下方向のチェック

左上と同じくマス目はないので挟める石も存在しません。

下方向のチェック

行番号に1を、列番号に0を加えた :2 :0 のマス目には白い石が置いあります。自分と同じ色の石に出合った場合、 sandwichStones に入っている石が挟める石となりますが、このチェックでは sandwichStones は空のままなので挟める石はないということになります。

右下方向のチェック

行番号に1を、列番号に1を加えた :2 :1 のマス目には黒い石が置いてるので sandwichStones を用意して入れておきます。
さらに行番号に1を、列番号に1を加えた :3 :2 のマス目には石がないので挟める石もありません。

このマス目に石を置いたとしたら、右方向のチェックをしたときの sandwichStones に入っている石が挟める石となります。

Boardクラスの作成

BoardクラスはSquareオブジェクトを管理して、石が置けるマス目のチェックなどを行います。

Boardクラスには次のようなプロパティやメソッドがあります。

名前説明
directions八方向の移動方向を入れた配列
squaresSquareオブジェクトの二次元配列
calcSquareElementSize()マス目の1辺の長さを計算する
createBaseElements()マス目の基となるdiv要素たちを作成する
createSquares()Squareオブジェクトの二次元配列を作成する
setInitialStones()初期配置の石を置く
getStone(rowIndex, columnIndex)指定されたマス目の石を取得する
getSandwichStones(rowIndex, columnIndex, direction)指定されたマス目に石を置くと挟むことができる石たちを取得する(一方向)
getAllSandwichStones(rowIndex, columnIndex)指定されたマス目に石を置くと挟むことができる石たちを取得する(八方向)
checkSelectableSquares()石を置くことができるマス目をチェックする
resetSelectableSquares()石を置くことができるマス目をリセットする
reset()盤面にあるすべての石を除去したあと、初期配置の石を置く

盤面をパソコンやスマホで表示するとき、画面ぴったりに表示させたいのでマス目1辺の長さを計算します。

しかし、あまりにもマス目が小さいとクリックしずらくなってしまうため、マス目1辺の最小サイズは30pxということにしています。

Board.jsに次のように書き込みます。

Board.js

class Board {
directions = [
{ row: -1, column: -1 },
{ row: -1, column: 0 },
{ row: -1, column: 1 },
{ row: 0, column: -1 },
{ row: 0, column: 1 },
{ row: 1, column: -1 },
{ row: 1, column: 0 },
{ row: 1, column: 1 },
];
constructor() {
this.createBaseElements();
this.createSquares();
this.setInitialStones();
}
calcSquareElementSize () {
// 画面内に盤面が収まるように、マス目の縦幅と横幅の一片の長さを計算する
const widthSize = window.innerWidth / initialBoard[0].length;
const heightSize = window.innerHeight / initialBoard.length;
// マス目は正方形にするため、これらのうち小さいほうを一片の長さとする
const size = Math.min(widthSize, heightSize);
// マス目が小さすぎるとクリックしずらくなってしまうため、1辺の最小サイズは30pxとする
return Math.max(size, 30);
};
createBaseElements () {
// マス目1辺の長さを計算する
const squareSize = this.calcSquareElementSize();
// 親要素はdisplay:inline-gridとなっている
const boardElement = document.getElementById('board');
boardElement.style.gridTemplateRows = `repeat(${initialBoard.length}, ${squareSize}px)`;
boardElement.style.gridTemplateColumns = `repeat(${initialBoard[0].length}, ${squareSize}px)`;
// fragmentにdiv要素を入れていく
// gridElementに直接div要素を入れていくとその都度、ブラウザが再描画を行うため負荷がかかってしまう
const fragment = document.createDocumentFragment();
forEachNest(initialBoard, (contentType, rowIndex, columnIndex) => {
const divElement = document.createElement('div');
divElement.id = `index-${rowIndex}-${columnIndex}`;
fragment.append(divElement);
});
// fragmentを使えばブラウザが再描画を行うのはこの一回のみですむ
boardElement.append(fragment);
};
createSquares () {
this.squares = initialBoard.map((row, rowIndex) => {
return row.map((contentType, columnIndex) => {
// 何もない空間にはnullを、それ以外はSquareオブジェクトを生成する
return contentType === -2 ? null : new Square(rowIndex, columnIndex);
});
});
}
setInitialStones () {
forEachNest(initialBoard, (contentType, rowIndex, columnIndex) => {
// 数字が0以上の場合は石が置いてあることを意味する
if (contentType >= 0) {
// 対応するColorオブジェクトを取得する
const color = state.colors[contentType];
// このマス目に石を置く
this.squares[rowIndex][columnIndex].setNewStone(color);
}
});
}
getStone (rowIndex, columnIndex) {
// 指定された場所の石を取得する。マス目が無い空間、または石が無い場合はnullが返される
return this.squares[rowIndex] && this.squares[rowIndex][columnIndex]?.stone || null;
};
getSandwichStones (rowIndex, columnIndex, direction) {
let currentRowIndex = rowIndex;
let currentColumnIndex = columnIndex;
// 挟むことのできる石をこの配列に入れていく
const sandwichStones = [];
while (true) {
// マス目の場所を指定された方向に移動させる
currentRowIndex += direction.row;
currentColumnIndex += direction.column;
// 移動した場所にある石を取得する
const stone = this.getStone(currentRowIndex, currentColumnIndex);
// もし石がない場合は挟むことのできる石がないため空の配列を返す
if (stone === null) {
return [];
}
// もし自分の色と同じ石が存在していたら、これまでsandwichStonesに
// 入れた石は挟むことができるのでsandwichStonesを返す
if (stone.color === state.currentPlayer.color) {
return sandwichStones;
}
// 自分以外の石が存在しているため配列にその石を追加する
sandwichStones.push(stone);
};
};
getAllSandwichStones (rowIndex, columnIndex) {
// 挟むことのできる石たちこの配列に入れていく
const allSandwichStones = [];
this.directions.forEach((direction) => {
// 一方向の挟むことのできる石たちを取得する
const sandwichStones = this.getSandwichStones(rowIndex, columnIndex, direction);
// それを配列に追加していく
allSandwichStones.push(...sandwichStones);
});
return allSandwichStones;
};
checkSelectableSquares () {
// 石を置くことができるマス目をこの配列に入れていく
const selectableSquares = [];
forEachNest(this.squares, (square, rowIndex, columnIndex) => {
// マス目が存在していて、かつ石が置いていない場合
if (square && square.stone === null) {
// 挟むことのできる石の配列を取得する
// 空の配列の場合もありうる
const sandwichStones = this.getAllSandwichStones(rowIndex, columnIndex);
// 挟むことのできる石があった場合
if (sandwichStones.length !== 0) {
// このマス目(Squareオブジェクト)にsandwichStonesをセットする
square.setSandwichStones(sandwichStones);
selectableSquares.push(square);
}
}
});
state.selectableSquares = selectableSquares;
}
resetSelectableSquares () {
state.selectableSquares.forEach((square) => {
// 挟める石の配列を空にする
square.setSandwichStones([]);
});
state.selectableSquares = [];
}
reset () {
forEachNest(this.squares, (square, rowIndex, columnIndex) => {
// もし石が置いてあるマス目ならば石を除去する
if (square && square.stone) {
square.removeStone();
}
});
// 初期配置の石を置く
this.setInitialStones();
}
}

盤面のCSSを追加する

styles.cssに次のものを書きくわえます。

styles.css

#board {
display: grid;
margin: 10px 0;
max-width: 100%;
overflow-x: auto;
}