はじめに
以前ご紹介させていただいた、下記記事からの続編(第⑤弾)になります。
プログラムの土台部分については、下記の記事で紹介しています。
今回は前回作ったものをベースに、新たに赤色の石を追加した、計7色の石を使った、3(+2)人対戦で遊べる変則リバーシを作りましたので、ご紹介したいと思います。
できたもの
以下のような、ブラウザで遊べるものを作りました。
自身が先手(黒)で、相手(白と灰、シアン、山吹)はコンピュータ、としています。
画像クリックで実際に遊べます。(GitHub Pagesで公開しています)
※今後の更新により、内容が変更される場合がございますが、ご了承ください。
ChromeとEdgeでの動作は確認しておりますが、他のブラウザをお使いの場合、正しく動作しない可能性がございます。そのような場合は、ぜひお知らせ下さい。
ソースコードなど一式は、以下に置いております。
ということで、お見せしたい結果も出揃いましたので
簡単ではありますが、作った上でのポイントを説明していきたいと思います。
赤色の石とは
"赤色の石"は私が思いついた、第七の石です。(といっても、同じようなものは既にいろいろな方が、作られているとは思います)
この赤色の石は、さわると爆発してしまうボムのようなもので、この石が挟まれると関係するすべての石が消えるという性質を備えた石とします。
また、この石を打つことができるプレイヤーはおらず(こんな石がたくさんあると、危なっかしいですからね)、単なる置き石の扱いとします。
それでは、赤色の石の性質がどのようなものか、具体的に示していきたいと思います。
石が消えるパターン
赤色の石がどのように振る舞うか、まずは簡単な例として以下の盤面でその様子を示したいと思います。
手番は"黒"の場合で考えます(白や灰も色が変わるだけで同じ扱いとなります)。上記の盤面で、石が置ける場所は以下の黄色で示した①~③の箇所になります。
それぞれの箇所に置いたときの結果と、石が消える理由を以下にまとめます。
番号 | 置いた結果 | 消える理由 |
---|---|---|
① | ・置いた黒と一番上の黒は、赤を挟む事に関係したため | |
② | ・置いた黒と一番上の黒は、赤を挟む事に関係したため ・白と灰は、赤が挟まれたタイミングでひっくり返されることに関係したため |
|
③ | ・置いた黒と上から二番目の黒は、赤を挟む事に関係したため ・白と灰は、赤が挟まれたタイミングでひっくり返されることに関係したため ・一番上の黒は赤を挟む事に関係していないため消えない |
上記から、赤色の石は以下の性質を備えたもの、と言い換えることができます。
- 赤の石が挟まれると、挟んだ石は消える
- 赤の石が挟まれると、その時ひっくり返る石は消える
複数列が関係しているパターン
冒頭で、赤色の石は関係するすべての石が消えるという性質を備えると前置きしました。この特徴を端的に理解するための例として、もう少し複雑な以下の盤面を用いたいと思います。
こちらも、手番は"黒"の場合で考えます。
上記盤面で、石が置ける場所は以下の黄色で示した①~③の箇所になります。
それぞれの箇所に置いたときの結果を以下にまとめます。
番号 | 置いた結果 | 消える理由 |
---|---|---|
① | 黒を置いた右のラインで赤が挟まれたため、その部分+その時同時にひっくり返る、右下および下のラインの石もすべて消える。 | |
② | 黒を置いた下のラインで赤が挟まれたため、その部分が消える | |
③ | 黒を置いた左のラインで、緑が緑のまま振る舞うことで左端の黒により赤が挟まれたため、その部分+その時同時にひっくり返る、上のラインの黒まで(赤は除く)の石もすべて消える。 |
このような赤色の石の振る舞いにより、関係するすべての石が消えることが分かるかと思います。
赤色の石の実装
作りたいものが決まったところで、さっそく実装に入っていきます。
赤色の石の実装には、大きく以下を行いました。
- 石の描画を追加
- 石の性質を追加
- 石を消す描画処理を追加
石の描画を追加する
これまで追加した石の時と同様、盤面を表す状態に"赤"を追加し、初期配置を変えます。あわせて、描画用の設定も追加します。
(board.js)
const H = 0; // 穴
const E = 1; // 空きマス
const B = 2; // 黒色の石
const W = 3; // 白色の石
const A = 4; // 灰色の石
const C = 5; // シアン色の石
const Y = 6; // 山吹色の石
const G = 7; // 緑色の石
+const R = 8; // 赤色の石
const BOARD = [
H, H, H, H, H, H, H, H, H, H, H, H,
H, H, H, H, H, E, E, H, H, H, H, H,
- H, H, H, H, E, E, E, E, H, H, H, H,
+ H, H, H, H, E, E, R, E, H, H, H, H,
H, H, H, E, E, E, E, E, E, H, H, H,
H, H, E, E, E, G, C, E, E, E, H, H,
- H, E, E, E, Y, W, B, G, E, E, E, H,
+ H, E, R, E, Y, W, B, G, E, E, E, H,
- H, E, E, E, G, B, W, Y, E, E, E, H,
+ H, E, E, E, G, B, W, Y, E, R, E, H,
H, H, E, E, E, C, G, E, E, E, H, H,
H, H, H, E, E, E, E, E, E, H, H, H,
- H, H, H, H, E, E, E, E, H, H, H, H,
+ H, H, H, H, E, R, E, E, H, H, H, H,
H, H, H, H, H, E, E, H, H, H, H, H,
H, H, H, H, H, H, H, H, H, H, H, H,
];
(ui.js)
const UI_PLAYER_INFO = {
[B]: {
'name': 'Black',
'img' : './image/black.png',
},
[W]: {
'name': 'White',
'img' : './image/white.png',
},
[A]: {
'name': 'Ash',
'img' : './image/ash.png',
},
[C]: {
'name': 'Cyan',
'img' : './image/cyan.png',
},
[Y]: {
'name': 'Yamabuki',
'img' : './image/yamabuki.png',
},
[G]: {
'name': 'Green',
'img' : './image/green.png',
},
+ [R]: {
+ 'name': 'Red',
+ 'img' : './image/red.png',
+ },
};
ブラウザに表示した結果が以下になります。
すぐには石が消せないよう、赤色の石は少し離れた位置に置いてみました。
石の性質を追加する
挟むと関連する石を消す赤色の石の性質を追加するために、以下を行いました。
- 手番の相手を返す処理に赤を追加
- ひっくり返せる石を取得する処理に赤色の石を追加する
- 石を消す処理を追加する
手番の相手を返す処理に赤を追加
手番の"相手"の定義に赤を追加して、以下のように変更しています。
- 自身が黒の場合は、白か灰かシアンか山吹か緑か赤
- 自身が白の場合は、灰か黒かシアンか山吹か緑か赤
- 自身が灰の場合は、黒か白かシアンか山吹か緑か赤
const FLIPPERS = { // 使用可能な全プレイヤー情報(石をひっくり返せる)
[B]: { // 黒
'player' : new Player(HUMAN), // 人が操作
'opponents': [W, A, C, Y, G, R], // 対戦相手 + 置き石
'score' : 0, // 黒の石の数
},
[W]: { // 白
'player' : new Player(MCS), // コンピュータが操作(原始モンテカルロ探索)
'opponents': [B, A, C, Y, G, R], // 対戦相手 + 置き石
'score' : 0, // 白の石の数
},
[A]: { // 灰
'player' : new Player(RANDOM), // コンピュータが操作(ランダム)
'opponents': [B, W, C, Y, G, R], // 対戦相手 + 置き石
'score' : 0, // 灰の石の数
},
[C]: { // シアン
'player' : new Player(MINIMUM), // コンピュータが操作(Minimum)
'opponents': [B, W, A, Y, G, R], // 対戦相手 + 置き石
'score' : 0, // シアンの石の数
},
[Y]: { // 山吹
'player' : new Player(MAXIMUM), // コンピュータが操作(Maximum)
'opponents': [B, W, A, C, G, R], // 対戦相手 + 置き石
'score' : 0, // 山吹の石の数
},
};
ひっくり返せる石を取得する処理に赤色の石を追加する
赤色の石の性質を決める、本記事でもっとも重要な部分になります。
挟むと関連する石を消す性質をもった石をボム石(BOMB
)と定義し、これに赤色の石を割り当てました。また、関連する石を消すにあたり、以下の処理が追加になります。
- ひっくり返した石にボム石が含まれているかどうかを返す(
erasable
) - 相手を挟んだ自分の石を返す(
flippers
)
後の処理で、erasable
を見てtrue
であれば、関連する石を消すという処理を行います。
相手を挟んだ際のボム石の処理は、自分の石で挟んだ場合と不変石(緑色の石)で挟んだ場合それぞれに追加しています。
(board.js)
const BOMB = R; // ボム石
// 打てる手を取得する処理
// (引数)
// turn : プレイヤーの手番(色)
// board : 盤面情報を格納した配列
// (戻り値)
// legalMoves : 打てる手(マスを表す番号)の配列
function getLegalMoves(turn, board) {
let legalMoves = [];
for (let i=0; i<board.length; i++) {
- const flippables = getFlippablesAtIndex(turn, board, i);
+ const {flippables, flippers, erasable} = getFlippablesAtIndex(turn, board, i);
if (flippables.length > 0) legalMoves.push(i);
}
return legalMoves;
}
// ひっくり返せる石を取得する処理
// (引数)
// turn : プレイヤーの手番(色)
// board : 盤面情報を格納した配列
// index : 石を置く位置(マスを示す番号)
// (戻り値)
// return : ひっくり返せる石、挟んだ石、消せるかどうか
function getFlippablesAtIndex(turn, board, index) {
let flippables = [];
- if (board[index] !== E) return flippables; // 空きマス以外はスキップ
+ let flippers = [];
+ let erasable = false;
+ if (board[index] !== E) return {
+ 'flippables': flippables,
+ 'flippers' : flippers,
+ 'erasable' : erasable,
+ }; // 空きマス以外はスキップ
const opponents = getOpponentColors(turn);
const size = Math.sqrt(board.length);
for (let {x, y} of DIRECTION_XY) {
const dir = (size * y) + x;
let opponentDiscs = [];
let next = index + dir;
// 相手ディスクが連続しているものを候補とする
while (opponents.includes(board[next])) {
opponentDiscs.push(next);
next += dir;
}
// 連続が途切れた箇所が自ディスクの場合、候補を戻り値に追加
if (board[next] === turn) {
flippables = flippables.concat(opponentDiscs);
+ // 挟んだ側の石を記憶
+ if (opponentDiscs.length > 0) flippers.push(next);
+ // ボム石が見つかったら記憶
+ if (opponentDiscs.map(e => board[e]).includes(BOMB)) erasable = true;
}
else {
while (opponentDiscs.length) {
// 候補をpopし、変化石を探す
- if (board[opponentDiscs.pop()] === WILDCARD) {
+ const pre = opponentDiscs.pop();
+ if (board[pre] === WILDCARD) {
// 変化石が見つかったら、残りの候補を戻り値に追加
flippables = flippables.concat(opponentDiscs);
+ // 挟んだ側の石を記憶
+ if (opponentDiscs.length > 0) flippers.push(pre);
+ // ボム石が見つかったら記憶
+ if (opponentDiscs.map(e => board[e]).includes(BOMB)) erasable = true;
break;
}
}
}
}
- return flippables;
+ return {'flippables': flippables, 'flippers': flippers, 'erasable': erasable};
}
石を消す処理を追加する
石を消す処理は、石を置く処理の中に追加しました。その後の描画のために、挟んだ石(flippers
)と消せるかどうか(erasable
)を戻り値に追加しています。
石が消せるかどうかの分岐が入り、大きく作りが変わったのでコードそのままを載せます。
// 石を置く処理
// (引数)
// turn : プレイヤーの手番(色)
// board : 盤面情報を格納した配列
// index : 石を置く位置(マスを示す番号)
// (戻り値)
// return : 置いた石、ひっくり返した石、挟んだ石、消せるかどうか
function putDisc(turn, board, index) {
if (index === NO_MOVE) return {'put': NO_MOVE, 'flipped': [], 'flippers': [], 'erasable': false};
const {flippables, flippers, erasable} = getFlippablesAtIndex(turn, board, index);
if (erasable === true) {
for (let erase of flippables.concat(flippers)) board[erase] = E; // 石を消す
}
else {
board[index] = turn; // 手の位置にディスクを置く
for (let flippable of flippables) { // 相手のディスクをひっくり返す
if (!ROCKS.includes(board[flippable])) board[flippable] = turn; // 置き石はひっくり返さない
}
}
return {'put': index, 'flipped': flippables, 'flippers': flippers, 'erasable': erasable};
}
石を消す描画処理を追加
さて、あともう一息です。
最後に、石が消える際の描画処理を追加していきます。変更した部分は以下になります。
- 石が消える際はひっくり返した石だけでなく、挟んだ石も更新する
- 盤面に石が置かれていない(
E
となっている)時は、石の画像を削除する
(ui.js)
// 石を更新
function updateDiscs() {
- const {put, flipped} = reversi.updatedDiscs;
+ const {put, flipped, flippers, erasable} = reversi.updatedDiscs;
if (put === NO_MOVE) return;
// 前回の手のハイライトを消す
if (prePut !== NO_MOVE) {
const square = document.getElementById(UI_BOARD + prePut);
square.style.backgroundColor = COLOR_CODE[BOARD_COLOR[prePut]];
}
// 石を置く
- setupDiscs(flipped.concat(put));
+ const updated = erasable === true ? flipped.concat(put, flippers) : flipped.concat(put);
+ setupDiscs(updated);
// 今回の手をハイライト
document.getElementById(UI_BOARD + put).style.backgroundColor = COLOR_CODE['7'];
prePut = put;
}
// 石を並べる
// (引数)
// indexs : 石を置く位置(マスを示す番号)の配列
function setupDiscs(indexs) {
for (let index of indexs) {
const square = document.getElementById(UI_BOARD + index);
const disc = reversi.board[index];
- if (disc in UI_PLAYER_INFO) setImg(square, UI_PLAYER_INFO[disc].img);
+ if (disc in UI_PLAYER_INFO) {
+ setImg(square, UI_PLAYER_INFO[disc].img);
+ }
+ else if (disc === E) {
+ removeChilds(square);
+ }
}
}
実は他にも関連する部分が、こまごまと変わっているのですが、いずれもあまり本質的な内容ではないため省略させていただきます。
完成
以上で、今回作ろうと思っていた、7色の石を使った、3(+2)人対戦で遊べる変則リバーシが完成しました。
今回の変更で、ひとまず追加したいなぁと思っていた石は、とりあえず全て実装し終えました。また何か思いついたら追加するかもしれませんが、さすがにてんこ盛りにし過ぎでは?とも思い始めていますね。
赤色の石は思っていた以上にダイナミックに局面を変えてしまうので、あまり深く考えず、思いがけない展開を楽しむゲームといった趣きに仕上がりました。よかったら、一度遊んでみて下さい。(冒頭にリンクがございます)
完成したものをプレイしている様子が、以下になります。
(一度にたくさん消すと、焼け野原のようになってそのまま終了してしまう、なんてことも起こったりします)
おまけ
役者(石)が揃ったところで、描画に一工夫入れて少し動きを加えてみたいと思います。
JavaScriptのasync/awaitを使うと、非同期処理が行えるという事で、これを使って石を置くときのアニメーションを追加してみます。
…といっても、ごく簡単なものなんですが、次の仕様とします。
基本的な動き
(1)石を置く(同時にハイライト) → (2)挟まれた石が横を向く → (3)ひっくり返された石の色が変わる
石が横を向いた際は、左側が挟んだ石の色、右側が挟まれた石の色となるようにします。
不変石を挟んだ場合
不変石を挟んだ場合は、石が横を向いた時に左右両方とも同じ色とします。
変化石で相手を挟んだ場合
変化石で相手を挟んだ場合は、相手の石をひっくり返す間、その変化石を手番の石の色に変化させます。
ボム石を挟んだ場合
ボム石を挟んだ場合は、石を横向きにせず、すべての石をボム石と同じ色にしたあと、消すようにします。
横向きの石の描画
横向きの石は、ulタグを使って、各石の色の長方形の画像を2つ並べて実現しました。
colorful-reversi
└─image // 画像ファイル
black_f.png
white_f.png
ash_f.png
cyan_f.png
yamabuki_f.png
green_f.png
red_f.png
色 | 画像 |
---|---|
黒 | |
白 | |
灰 | |
シアン | |
山吹 | |
緑 | |
赤 |
石を横並びにしてマス目の中央に寄せるため、CSSに以下を追加しています。(これを見つけるのに一番苦労しました)
ul { /* (横向きの石画像の並び) */
display: flex; /* 要素を横並びにする */
justify-content: center; /* 要素を中央寄せにする */
margin: auto; /* 周囲の余白 : 自動調整 */
list-style-type: none; /* liのビュレットを消す */
padding-left: 0; /* 左側の余白をとる */
}
コード
石を置いてひっくり返すアニメーションを実装したコードが以下になります。
const UI_PLAYER_INFO = {
[B]: {
'name': 'Black',
'img' : './image/black.png',
'imgf': './image/black_f.png',
},
[W]: {
'name': 'White',
'img' : './image/white.png',
'imgf': './image/white_f.png',
},
[A]: {
'name': 'Ash',
'img' : './image/ash.png',
'imgf': './image/ash_f.png',
},
[C]: {
'name': 'Cyan',
'img' : './image/cyan.png',
'imgf': './image/cyan_f.png',
},
[Y]: {
'name': 'Yamabuki',
'img' : './image/yamabuki.png',
'imgf': './image/yamabuki_f.png',
},
[G]: {
'name': 'Green',
'img' : './image/green.png',
'imgf': './image/green_f.png',
},
[R]: {
'name': 'Red',
'img' : './image/red.png',
'imgf': './image/red_f.png',
},
};
const ANIMATION_WAIT = 350; // アニメーションのウェイト時間(ms)
const FLIPPING_IMAGE_DIV = 7; // ひっくり返す際の画像横幅調整値
let preBoard = BOARD;
// 石を更新
async function updateDiscs() {
if (reversi.updatedDiscs.put === NO_MOVE) return;
putAnimation();
await flippingAnimation();
await flippedAnimation();
}
// 石を置くアニメ―ジョン
function putAnimation() {
// 前回のハイライトを消す
if (prePut !== NO_MOVE) {
const square = document.getElementById(UI_BOARD + prePut);
square.style.backgroundColor = COLOR_CODE[BOARD_COLOR[prePut]];
}
// 石を置く
const {put, flipped, flippers, erasable} = reversi.updatedDiscs;
setupDiscs([put]);
// 今回の手をハイライト
const square = document.getElementById(UI_BOARD + put);
square.style.backgroundColor = COLOR_CODE['7'];
prePut = put;
// ボム石効果
if (erasable === true ) {
setupSpecifiedDiscs(flipped.concat(put, flippers), BOMB);
}
// 変化石効果
else if (flippers.map(e => reversi.board[e]).includes(WILDCARD)) {
setupSpecifiedDiscs(flippers, reversi.board[put]);
}
}
// 盤面の石を並べる
// (引数)
// indexs : 石を置く位置(マスを示す番号)の配列
function setupDiscs(indexs) {
for (let index of indexs) {
const square = document.getElementById(UI_BOARD + index);
const disc = reversi.board[index];
if (disc in UI_PLAYER_INFO) {
setImg(square, UI_PLAYER_INFO[disc].img);
}
else if (disc === E) {
removeChilds(square);
}
}
}
// 指定の石を並べる
// (引数)
// indexs : 石を置く位置(マスを示す番号)の配列
// disc : 石の種類
function setupSpecifiedDiscs(indexs, disc) {
for (let index of indexs) {
const square = document.getElementById(UI_BOARD + index);
if (disc in UI_PLAYER_INFO) {
setImg(square, UI_PLAYER_INFO[disc].img);
}
}
}
// 石を返す途中のアニメ―ジョン
function flippingAnimation() {
return new Promise(resolve => {
setTimeout(() => {
resolve(flippingDiscs());
}, ANIMATION_WAIT);
})
}
// 石を返す途中の画像表示
function flippingDiscs() {
const {put, flipped, flippers, erasable} = reversi.updatedDiscs;
if (erasable === false) {
for (let index of flipped) {
let right = preBoard[index];
let left = reversi.board[index];
const square = document.getElementById(UI_BOARD + index);
setFlipImg(square, UI_PLAYER_INFO[left].imgf, UI_PLAYER_INFO[right].imgf);
}
}
preBoard = reversi.board.concat();
}
// 石を返した後のアニメ―ジョン
function flippedAnimation() {
return new Promise(resolve => {
setTimeout(() => {
resolve(flippedDiscs());
}, ANIMATION_WAIT);
})
}
// 石を返した後の画像表示
function flippedDiscs() {
const {put, flipped, flippers, erasable} = reversi.updatedDiscs;
setupDiscs(flipped.concat(put, flippers));
}
// ひっくり返し途中の画像を配置
// (引数)
// element : Document要素
// imgPath1 : 画像のパス1(左)
// imgPath2 : 画像のパス2(右)
function setFlipImg(element, imgPath1, imgPath2) {
removeChilds(element); // 一旦、子要素削除
const img1 = document.createElement('img'); // 画像要素作成
img1.src = imgPath1; // 画像パス
img1.width = imageSize/FLIPPING_IMAGE_DIV; // 横サイズ(px)
img1.height = imageSize; // 縦サイズ(px)
const img2 = document.createElement('img'); // 画像要素作成
img2.src = imgPath2; // 画像パス
img2.width = imageSize/FLIPPING_IMAGE_DIV; // 横サイズ(px)
img2.height = imageSize; // 縦サイズ(px)
const li1 = document.createElement('li');
li1.appendChild(img1); // 画像追加
const li2 = document.createElement('li');
li2.appendChild(img2); // 画像追加
const ul = document.createElement('ul');
ul.appendChild(li1);
ul.appendChild(li2);
element.appendChild(ul);
}
ひっくり返す前の石を保持するため、盤面情報の前回値保持を追加しました。
また、アニメーションの処理はそれぞれ以下が対応します。
- 石を置く →
putAnimation()
; - ひっくり返す際中 →
await flippingAnimation()
; - ひっくり返した後 →
await flippedAnimation()
;
横向き画像は、マス目にulタグを追加して左右に長方形の画像を並べています。
あとは、ウェイト(待ち時間)の調整も入れています。
(game.js)
-const WAIT_TIME = 800; // ウェイト時間(ms)
+const WAIT_TIME = 1500; // ウェイト時間(ms)
結果
思ったより地味でしたが、ハイライトを追加した時と同様に無いよりはマシかなと思います。思いがけずボム石を挟んでしまい、真っ赤に染まった石を一瞬見て焦ってもらえたりしたら大変嬉しいです。async/awaitを使えば、この調子でアニメーションのコマを増やしてもっとすごい演出にもできそうですね。
最後に
赤色の特殊な石が加わった、変化に富んだリバーシを作ってみましたが、いかがでしたでしょうか。赤色の石をいつ消すべきなのか、興味がわくところでもあります。(さっさと消して、相手に何もさせずに勝つという戦略もあるかもしれませんね)
最後まで読んで下さり、ありがとうございました。
最後の最後に、手前味噌で申し訳ありませんが、次の記事を書くまでのつなぎとして、より様々な変則盤面で遊べる自作のリバーシゲームをご紹介します。以下のリンク先で遊べますので、変則リバーシ好きのあなた、ぜひ一度お試しください!!
それでは皆様、よきリバーシライフを!