オセロを覚えた息子
学校でオセロを覚えてきた小学1年生の息子は家でもオセロをやりたいと言ってきます。
玩具として購入しても良いのですが、ただでさえものが溢れて片付けられず散らかる状態なので、iPadのゲームをインストールして遊んでいます。
一方で広告がかなり邪魔になります。(それに最近の広告は少し教育に悪い下品なものが多い。)
ちゃんと課金しないさいという事なのでしょうが、コンピュータとの対戦やネットワーク対戦が不要な簡易なものでよければ、単純なJavaScriptで実装できそうです。
作成してみることにしました。
生成AIと一緒に機能追加や調整していくと短時間で構築できました。
このようなロジックが広く普及している対象物は生成AIと相性がとても良いですね。
どこでホスティングするか
簡易なHTMLファイル1枚で作成しましたが、どこでホスティングするのか悩みます。
AWS S3などが考えられますが、やりたいことに対して装備が大きすぎるように思います。
無料で簡易に設置できるスペースはないものか、と考えている中で、Markdown AIというサービスを知りました。
Markdown形式でWebサイトを作成できるサービスですが、試したところHTMLも記述できて、style要素やscript要素も許容されているようです。
すなわち今回のような単一HTMLで作成したブラウザゲームのホスティングができますね。
プレイ中の様子
出来ました。
早速、息子と遊んでみます。
CMSとAI融合の可能性
v0やCursorなどを利用していると、生成AIとサイト制作というのは混ざり合いこれまでの常識だった構築方法が根底からひっくり返ってきているように思います。
それがCMSの世界にも導入され、より効率の良いサイト更新体験や、生成されるコンテンツ自体がAIと混ざり合う世界になるかもしれません。
Markdown AIは自分だけのサイトに特化したAIモデルを作成することができるということなので、CPU対戦を実装する場合などに活用できるのかもしれません。
試しに、「AIに聞く」というボタンをゲームに追加し、代わりに次の一手を考えてもらう実装をしてみました。
Prompt
あなたはオセロの達人です。
盤面の状態をJSON形式で伝えるので、次に置くべき最適なの盤面の位置を `[col, row]` の形式で一つだけ返却してください。(例えば、あなたが次の一手を一番左上に置くべきだと考えた場合は `[0,0]` のようになります。添字は0から始まります。)
返答時にこの配列データ以外を返却する必要するはありません。バッククオートなどで囲む必要もありません。
私が送信するJSONは下記のとおりです。
- boardは盤面を示します。入れ子の配列によって8x8マスを表します。行の配列の中に列の配列が入ります。
- 黒を"B"、白を"W"、次のターンで石が置けるマスplaceableを"P"として表現しています。
- nextPlayerで次に手番を示します。
- あなたはJSONのboardを参照してセルが"P"となっているものから、一番nextPlayerにとって優位となる場所を一つ選んで回答してください。
- boardで"."や"B"や"W"となっているセルを選んでは絶対にいけません。そうなった場合はもう一度考え直してください。
実際にAI側に送信するJSON例
{
"board":[
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."],
[".",".","P","B","P",".",".","."],
[".",".",".","B","B",".",".","."],
[".",".","P","B","W",".",".","."],
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."]
],
"nextPlayer":"W"
}
ゲームプログラムも若干修正し、「AIに聞く」ボタンを押すと、次にどこに置くべきかオレンジ色でハイライトしてくれるようになりました。
課題
置けないところに置こうとする
置けないところのセルを指定することがあります。
置けるセルを考えさせるのが良くないと思い、JSONにplaceableの情報を付加して送っても、どのAIモデルを用いても精度が高くなりませんでした。
Create Model
画面から Playground
ボタンで遷移して対話すると間違いを正してくれるのですが、ゲームにその試行を自動で繰り返す処理をいれるのは手間がかかるので、ひとまずそのままとしました。
このため、無茶苦茶な場所を指し示すことがあります。(そこは御愛嬌ということで..)
原因不明な500(Internal Server Error)エラー
Promptの定義は具体的にServerAIに送りつけるJSON情報の例を書きたかったのですが、どうやらここでは入力してはいけない文字など制限があるようです。
getAnswerText
によってアクセスするAPIがずっと500エラーとなり、手こずりました。
エラーとなる場合は簡易な文言での定義からやり直すと良さそうです。
完成品
この記事の中で作成したものは下記に公開しています。
実装コード一式
<style>
.column {
padding: 0 !important;
}
.text-preview {
margin: 0 !important;
}
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
text-align: center;
margin: 0;
padding: 0;
}
#board-wrappper {
position: absolute;
left: 0;
top: min(13vw, 13vh);
right: 0;
bottom: min(1vw, 1vh);
display: flex;
justify-content: center;
align-items: center;
}
#board-frame {
position: relative;
width: 100%;
height: 100%;
max-width: min(100%, calc(100vh - 14vh));
max-height: min(100%, calc(100vh - 14vh));
aspect-ratio: 1;
}
#board {
position: absolute;
left: 0;
top: 0;
width: 100%;
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 1px;
background-color: #000;
}
.cell {
width: 100%;
padding: 0 0 100%;
background-color: green;
display: flex;
justify-content: center;
align-items: center;
font-size: min(4vw, 4vh);
cursor: pointer;
margin: 0;
border: none;
position: relative;
}
.cell.black::after,
.cell.white::after {
content: '';
position: absolute;
width: 80%;
height: 80%;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.cell.black::after {
background: radial-gradient(circle at 30% 30%, #666, #000);
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4);
}
.cell.white::after {
background: radial-gradient(circle at 30% 30%, #fff, #ccc);
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.4);
}
.cell.placeable::after {
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
content: '';
display: block;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.cell.marker::before {
content: '';
position: absolute;
width: 8%;
height: 8%;
background-color: #000;
border-radius: 50%;
top: 0;
left: 0;
transform: translate(-50%, -50%);
}
h1 {
font-size: min(5vw, 5vh);
line-height: 1.5;
margin: 0;
}
button {
font-size: min(2.5vw, 2.5vh);
line-height: 1.5;
margin: 0;
background: linear-gradient(to bottom, #fff, #ccc);
font-size: min(2.5vw, 2.5vh);
line-height: 1.5;
min-height: min(4.5vw, 4.5vh);
border: 1px solid #000;
border-radius: 5px;
}
#status {
font-size: min(3vw, 3vh);
line-height: 1.5;
margin: 0;
}
#controls {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: min(1vw, 1vh);
top: min(1vw, 1vh);
gap: min(1vw, 1vh);
}
#controls select,
#controls button {
font-size: min(2.5vw, 2.5vh);
line-height: 1.5;
min-height: min(4.5vw, 4.5vh);
border: 1px solid #000;
border-radius: 5px;
}
#container {
width: 100%;
height: 100vh;
max-width: 100vh;
margin: 0 auto;
position: relative;
}
#score-wrapper {
position: absolute;
left: min(1vw, 1vh);
top: min(1vw, 1vh);
font-size: min(3vw, 3vh);
line-height: 1.5;
text-align: left;
margin: 0;
display: flex;
gap: min(2vw, 2vh);
align-items: start;
}
#score {
}
.cell.last-move {
box-shadow: inset 2px 2px 0 rgb(255,0,0), inset -2px -2px 0 rgb(255,0,0);
}
.cell.ai-suggestion {
background-color: rgba(255, 150, 0, 0.7);
}
</style>
<div id="container">
<div id="score-wrapper">
<div id="score">黒: 2<br>白: 2</div>
<button id="ask-ai">AIに聞く</button>
</div>
<h1>オセロ</h1>
<p id="status">黒の番です</p>
<div id="board-wrappper"><div id="board-frame"><div id="board"></div></div></div>
<div id="controls">
<select id="handicap">
<option value="0">ハンデなし</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<button id="reset" onclick="resetGame()">最初から</button>
</div>
</div>
<script>
const boardSize = 8;
const board = document.getElementById('board');
const status = document.getElementById('status');
let currentPlayer = 'black';
let cells = [];
const scoreElement = document.getElementById('score');
const handicapSelect = document.getElementById('handicap');
function initBoard() {
board.innerHTML = '';
cells = [];
for (let row = 0; row < boardSize; row++) {
const rowCells = [];
for (let col = 0; col < boardSize; col++) {
const cell = document.createElement('div');
cell.className = 'cell';
// マーカーを追加(2,2)(2,6)(6,2)(6,6)の位置
if ((row === 2 || row === 6) && (col === 2 || col === 6)) {
cell.classList.add('marker');
}
cell.dataset.row = row;
cell.dataset.col = col;
cell.onclick = () => placeDisc(row, col);
board.appendChild(cell);
rowCells.push(cell);
}
cells.push(rowCells);
}
// 初期配置
cells[3][3].classList.add('white');
cells[3][4].classList.add('black');
cells[4][3].classList.add('black');
cells[4][4].classList.add('white');
// ハンデの設定
const handicap = parseInt(handicapSelect.value);
const corners = [
[0, 0], [0, 7], [7, 0], [7, 7]
];
// 選択された数のコーナーに白石を配置
for (let i = 0; i < handicap; i++) {
const [row, col] = corners[i];
cells[row][col].classList.add('white');
}
}
function updatePlaceableCells() {
// すべてのセルからplaceableクラスを削除
cells.forEach(row => {
row.forEach(cell => {
cell.classList.remove('placeable');
});
});
// 置ける場所を探してplaceableクラスを追加
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
const cell = cells[row][col];
if (!cell.classList.contains('black') && !cell.classList.contains('white')) {
if (getFlippableDiscs(row, col, currentPlayer).length > 0) {
cell.classList.add('placeable');
}
}
}
}
}
function checkGameEnd() {
// 置ける場所があるか確認
let canPlace = false;
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
const cell = cells[row][col];
if (!cell.classList.contains('black') && !cell.classList.contains('white')) {
if (getFlippableDiscs(row, col, currentPlayer).length > 0) {
canPlace = true;
break;
}
}
}
if (canPlace) break;
}
if (!canPlace) {
// 相手も置けるか確認
currentPlayer = currentPlayer === 'black' ? 'white' : 'black';
let opponentCanPlace = false;
for (let row = 0; row < boardSize; row++) {
for (let col = 0; col < boardSize; col++) {
const cell = cells[row][col];
if (!cell.classList.contains('black') && !cell.classList.contains('white')) {
if (getFlippableDiscs(row, col, currentPlayer).length > 0) {
opponentCanPlace = true;
break;
}
}
}
if (opponentCanPlace) break;
}
if (!opponentCanPlace) {
// 両者とも置けない場合はゲーム終了
const blackCount = document.querySelectorAll('.cell.black').length;
const whiteCount = document.querySelectorAll('.cell.white').length;
if (blackCount > whiteCount) {
status.textContent = '黒の勝ちです!';
} else if (whiteCount > blackCount) {
status.textContent = '白の勝ちです!';
} else {
status.textContent = '引き分けです!';
}
return true;
} else {
status.textContent = `${currentPlayer === 'black' ? '黒' : '白'}の番です(相手がパスしました)`;
}
}
return false;
}
function placeDisc(row, col) {
const cell = cells[row][col];
if (cell.classList.contains('black') || cell.classList.contains('white')) {
return;
}
const flips = getFlippableDiscs(row, col, currentPlayer);
if (flips.length === 0) {
return;
}
// 前回の最後の手のマークを削除
document.querySelectorAll('.last-move').forEach(cell => {
cell.classList.remove('last-move');
});
// AIの提案表示を削除
document.querySelectorAll('.ai-suggestion').forEach(cell => {
cell.classList.remove('ai-suggestion');
});
cell.classList.remove('placeable');
// 石を置く
cell.classList.add(currentPlayer);
// 最後の手のマークを追加
cell.classList.add('last-move');
// 石をひっくり返す
flips.forEach(([r, c]) => {
cells[r][c].classList.remove('black', 'white', 'placeable');
cells[r][c].classList.add(currentPlayer);
});
// プレイヤーを切り替える
currentPlayer = currentPlayer === 'black' ? 'white' : 'black';
updateScore();
// 勝敗判定
if (!checkGameEnd()) {
status.textContent = `${currentPlayer === 'black' ? '黒' : '白'}の番です`;
updatePlaceableCells();
}
}
function getFlippableDiscs(row, col, player) {
const opponent = player === 'black' ? 'white' : 'black';
const directions = [
[-1, 0], [1, 0], [0, -1], [0, 1],
[-1, -1], [-1, 1], [1, -1], [1, 1]
];
const flips = [];
for (const [dr, dc] of directions) {
let r = row + dr;
let c = col + dc;
const potentialFlips = [];
while (r >= 0 && r < boardSize && c >= 0 && c < boardSize) {
const cell = cells[r][c];
if (cell.classList.contains(opponent)) {
potentialFlips.push([r, c]);
} else if (cell.classList.contains(player)) {
flips.push(...potentialFlips);
break;
} else {
break;
}
r += dr;
c += dc;
}
}
return flips;
}
function resetGame() {
// 最後の手のマークを削除
document.querySelectorAll('.last-move').forEach(cell => {
cell.classList.remove('last-move');
});
currentPlayer = 'black';
status.textContent = '黒の番です';
initBoard();
updatePlaceableCells();
updateScore();
}
function updateScore() {
let blackCount = 0;
let whiteCount = 0;
cells.forEach(row => {
row.forEach(cell => {
if (cell.classList.contains('black')) blackCount++;
if (cell.classList.contains('white')) whiteCount++;
});
});
scoreElement.innerHTML = `黒: ${blackCount}<br>白: ${whiteCount}`;
}
const button = document.getElementById('ask-ai');
button.addEventListener('click', async event => {
button.disabled = true;
const boardState = cells.map(row =>
row.map(cell => {
if (cell.classList.contains('black')) return 'B';
if (cell.classList.contains('white')) return 'W';
if (cell.classList.contains('placeable')) return 'P';
return '.';
})
);
const gameState = {
board: boardState,
nextPlayer: currentPlayer === 'black' ? 'B' : 'W'
};
console.log(JSON.stringify(gameState));
const serverAi = new ServerAI();
const answer = await serverAi.getAnswerText('7QF48dNYLW8hpQ9sbwd2hv', '', JSON.stringify(gameState));
// AIの提案を表示する処理を追加
// 前回のAI提案を削除
document.querySelectorAll('.ai-suggestion').forEach(cell => {
cell.classList.remove('ai-suggestion');
});
try {
const [col, row] = JSON.parse(answer);
cells[col][row].classList.add('ai-suggestion');
} catch (e) {
console.error('AIの応答を解析できませんでした:', e);
} finally {
button.disabled = false;
}
});
initBoard();
updatePlaceableCells();
updateScore();
</script>
少し先の未来
Mermaid記法対応など、ここ数日の間にも活発に機能追加しているようで、FAQをみると来年初めに独自ドメインを設定できる有料版を提供予定とのことです。
これからのさらなる進化が楽しみですね。
今回はオセロを題材にしましたが、以前に作成した下記の子ども向けゲームにも使えるなと思いました。
このときはクイズのお題を固定でもっていましたが、Markdown AIが提供するAI機能をうまく利用できそうです。
従来はそれぞれの生成AIサービスと契約してAPIキーを発行して連携プログラムを組んで、というように手順の多かったことがワンサービスで完結するのはとてもメリットになると思います。
参考情報
初めての方は公式アカウントの下記の記事が分かりやすいと思います。
Googleアカウントがあれば、無料ですぐに試せるので遊んでみてはいかがでしょうか。