実験#79:ファンタジー世界の対戦競技(魔法生体・解放競技会)

■79個目 「魔法生体・解放競技会」

こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/

今回は、魔法の世界で行われる優しい戦略バトル

「魔法生体・解放競技会」 を作ってみました!(^_^)

舞台となるのは、魔法が存在するものの、
力が少し弱くて不安定な小さな世界。

人々は魔力を補うために、さまざまな
「魔法の道具」に頼って暮らしています。

そんな世界でスポーツのように熱狂的に楽しまれているのが、
この試合形式の盤上ゲームなんです。(=v=*)

今回こだわったのは、「相手を倒す」のではなく
「解放する」 という優しいルール。

「海戦ゲームのような推理バトルは面白いけど、
船が沈むのはちょっと悲しいかも…?」
そんな気持ちから生まれたアイデアです。

そこで登場するのが、
相手の戦艦の代わりに配置された 巨大な魔法生物たち。

彼らはマス目ごとに取り付けられた「バチバチ魔法解除装置」によって、
魔力を封じられている状態なんです。

見えない相手フィールドに向かって魔法を放ち、
うまく命中すると装置が壊れていきます。

そしてすべての制御が外れると……

魔法生物たちは本来の可愛らしい姿(🐛や🐉など!)に戻って、
大自然へと「解放」されていきます。(´▽`*)

熱血実況者さんの楽しいトークが飛び交う中、
お互いに特殊な魔法スキルを駆使して、
どちらが先にすべての幻獣を自由にできるかを競います。

どんな試合展開になるのか――
このあと、詳しい操作方法や特殊スキルをご紹介しますね!

■ AIとのやりとりで面白かった部分

● ゲーム仕様の相談からスタート

みー:(^v^) 今日もゲームを作ります。html形式で動かせるゲーム。

魔法の存在はあるけど力が弱く、道具に頼って生活している世界。

そんなファンタジー世界で行われる、
実況付きの「海戦ゲーム」風の競技を作りたいです。

巨大な魔法生物を戦艦の代わりにして、
体の各マスに「バチバチ魔法解除装置」を設置して制御。

10×10マスのフィールドにランダム配置して、
ターン制で相手の見えないマスを攻撃します。

全部の制御を壊すと魔法生物が「解放」される仕組みです。

特殊攻撃は以下の5種類で、ファンタジーな名前はお任せします。

  • 1マス生物:レーダー(4マス四角の範囲に生物がいるか判定)
  • 2マス生物:2連撃
  • 3マス生物:3連撃
  • 4マス生物:十字攻撃(5マス)
  • 5マス生物:6マス絨毯攻撃

実況は1ターンごとに盛り上げてほしいです。

〈AIに記事用に原文を読みやすく調整してもらってますが、元の文は結構長いです〉(^^;)

AI: みーさん、こんにちは! 今回の設定、とても面白いです。
魔法生物を使った競技という発想が素敵ですね!

みー:(*^v^)実況のバリエーションを増やしたいです。
十字と絨毯攻撃は、どこに攻撃するかプレビューがあると嬉しい。
攻撃中の生物は色を変えて表示したい。リトライもお願いします。

AI: 了解です!実況のバリエーションを増やし、
攻撃範囲プレビューや可視化、リトライ機能も追加しました。

みー:(^o^)b 自軍の魔法生物をもっとかわいくしたい。
敵も解放されたらかわいく表示したい。
あと難易度設定をつけたいです。

AI: かわいい絵文字(🐛🐍🦕🐉🐲)でビジュアルを強化しました!

難易度も以下のように追加しています。

  • イージー:のんびりAI。たまに手加減。
  • ノーマル:標準AI。ランダム攻撃。
  • ハード:賢いAI。命中した生物を徹底的に狙う。

みー:(=v=`) 相手側、特殊攻撃ほとんど使ってこないね。
難易度で違うのかな。

AI: 前は一律25%だったので温存しがちでした。
今回は難易度ごとに発動率を調整しています。

ハード:60%

ノーマル:35%

イージー:15%

■ 実際の動作

ゲームのスタート画面です。
左側の自軍フィールドには、最初からランダムに魔法生物が設置されています。

(画像:スタート画面)

ターン制で、右側の敵軍フィールドへ予測しながら攻撃します。
通常攻撃は1マスずつ。
特殊攻撃をうまく使って、有利に試合を進めましょう。

(画像:ゲーム画面)

● 特殊攻撃の例
魔法生物それぞれに特殊能力があり、1回ずつしか使えません。
特殊能力を使う前にやられてしまうと、その試合では発動できません。

■ グランド・メテオ(5マス生物)
6マスの範囲を一気に攻撃できます。

(画像:グランド・メテオ)



命中した場所は赤く表示され、
外れは水滴の絵文字💧で表示されます。

■ ホーリー・クロス(4マス生物)
画像は5マス十字範囲攻撃の「ホーリー・クロス」です。

(画像:ホーリー・クロス)

■ トライ・バースト(3マス生物)

好きな場所を3つ攻撃できます。

■ ツイン・スパーク(2マス生物)

2か所を同時に攻撃できます。

(画像:トライ・バースト/ツイン・スパーク)

■ マナ・ソナー(1マス生物)

攻撃ではありませんが、
4マスの正方形範囲に魔法生物がいるかを探ります。
存在すると黄色く光ります。

(画像:マナ・ソナー)

先にすべての魔法生物を解放できたら勝利です!

(画像:勝利画面)


■ コードのポイント

今回の記事では、AI と相談しながら作った
「魔法生体・解放競技会」 の HTML コードを公開します。

このコードをそのまま保存するだけで、
あなたのPCでもすぐに遊べます。

ブラウザだけで動く、とてもシンプルなゲームなので、
気軽に試してみてくださいね。

▼ 遊ぶまでの手順

①PC の「メモ帳」(※Windowsの場合)や  
 Mac の「テキストエディット」など、テキストエディタを開きます。

②下のコードをコピーして貼り付けます。

③「mahouseibutu.html」など、好きな名前で保存します。
 (※拡張子は .html にしてください)

④保存したファイルをブラウザで開けば、すぐに遊べます。

 

■ 操作方法

「魔法生体・解放競技会」は、
マウスのクリックだけで遊べるシンプルなゲームです。(^_^)

【勝利条件】

相手フィールド(右側の盤面)に隠れている
5体の魔法生物 をすべて「解放」できれば勝利です!

【基本的な遊び方】

自分のターンになったら、
敵フィールドの怪しいマスをポチッとクリックします。

  • 💥 命中! 魔法生物の制御を1つ壊しました。
  • 💧 ハズレ 何もいません。

1体の魔法生物のすべてのマスに命中させると、
キラキラ光ってかわいい元の姿(🐛や🐉など)で
盤面に飛び出してきます。(´▽`*)

【一発逆転!? 特殊魔法】

画面下のボタンから、ド派手な「特殊攻撃」が使えます。(=v=*)
自分の魔法生物が生き残っている限り、
1試合につき各1回だけ使える切り札です!

  • 【1マス生物】マナ・ソナー
     攻撃はしませんが、4マスの正方形範囲をスキャン。  
     生物がいれば ✨ が光ります。
  • 【2マス生物】ツイン・スパーク
     好きなマスを2つ選んで連続攻撃!
  • 【3マス生物】トライ・バースト
     3か所を一気に攻撃できます。
  • 【4マス生物】ホーリー・クロス
     十字型の5マスをまとめて攻撃!
  • 【5マス生物】グランド・メテオ
     横3×縦2マスの範囲をドカーンと絨毯爆撃!

※範囲魔法を使うときは、敵フィールドにマウスを乗せると
 青いプレビューで攻撃範囲が見えるようになっています。

【難易度とBGM】

ゲーム開始前に、画面上のメニューから
イージー/ノーマル/ハード を選べます。

  • ハードのAI は、一度命中すると周囲を徹底的に狙ってくるのでかなり手強いです!
  • 「BGM: OFF」ボタンを押すと、懐かしいピコピコ音のメロディが流れます♪

どちらが先にすべての幻獣を自由にできるのか……

ぜひ、盤上の熱いバトルを楽しんでみてくださいね。(^^*)


“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>魔法生体・解放競技会 V5</title>
<style>
:root {
--bg-color: #eef2f7;
--board-bg: #2c3e50;
--cell-bg: #34495e;
--player-ship: #a2d9ce;
--hit-ship: #e67e22;
--freed: #f1c40f;
--miss: #95a5a6;
--radar: #f1c40f;
--preview: rgba(52, 152, 219, 0.6);
--text-main: #2c3e50;
}
body {
font-family: 'Hiragino Maru Gothic ProN', 'Meiryo', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
text-align: center;
margin: 0;
padding: 20px;
}
#settings-bar {
background: #fff;
padding: 10px 20px;
border-radius: 8px;
margin-bottom: 15px;
display: inline-block;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
font-weight: bold;
}
select { padding: 5px; font-size: 1em; border-radius: 4px; border: 1px solid #bdc3c7; }
.setting-btn { background-color: #27ae60; margin-left: 10px; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer;}
#bgm-btn { background-color: #8e44ad; margin-left: 10px; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-weight: bold;}
#commentary-box {
background-color: #000;
color: #00ff00;
border: 4px double #555;
border-radius: 4px;
padding: 15px;
margin: 0 auto 20px auto;
max-width: 850px;
font-size: 1.1em;
font-weight: bold;
min-height: 2.5em;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 10px rgba(0,255,0,0.2);
transition: color 0.3s;
}
.enemy-action { color: #ff9f43 !important; }
.game-container {
display: flex;
justify-content: center;
gap: 40px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.board-wrapper { display: flex; flex-direction: column; align-items: center; }
.board-title {
font-size: 1.1em;
font-weight: bold;
margin-bottom: 10px;
background: #2c3e50;
color: white;
padding: 5px 15px;
border-radius: 20px;
}
.board {
display: grid;
grid-template-columns: repeat(10, 36px);
grid-template-rows: repeat(10, 36px);
gap: 2px;
background-color: var(--board-bg);
padding: 4px;
border: 2px solid #1a252f;
border-radius: 6px;
}
.cell {
width: 36px;
height: 36px;
background-color: var(--cell-bg);
cursor: pointer;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
transition: background 0.2s, transform 0.1s;
user-select: none;
}
.ship { background-color: var(--player-ship); }
.hit { background-color: var(--hit-ship); }
.miss { background-color: var(--miss); }
.radar-hit { background-color: var(--radar); box-shadow: inset 0 0 8px #fff; }
.preview-area { background-color: var(--preview) !important; border: 1px solid white; }
.selected-mark { border: 2px solid #f1c40f; background-color: #d35400 !important; }
.freed {
background-color: var(--freed) !important;
box-shadow: 0 0 10px var(--freed);
animation: popup 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
z-index: 10;
}
@keyframes popup {
0% { transform: scale(0.5); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
#controls {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
max-width: 900px;
margin: 0 auto;
}
button {
padding: 10px 14px;
font-size: 0.95em;
border: none;
border-radius: 4px;
background-color: #34495e;
color: white;
cursor: pointer;
font-weight: bold;
transition: background-color 0.2s;
}
button:hover:not(:disabled) { background-color: #2c3e50; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.sp-btn { background-color: #8e44ad; }
.sp-btn:hover:not(:disabled) { background-color: #7d3c98; }
#retry-btn { background-color: #c0392b; font-size: 1.1em; display: none; padding: 12px 24px;}
#retry-btn:hover { background-color: #e74c3c; }
#status-info { margin-top: 10px; font-weight: bold; color: #e74c3c; min-height: 1.5em; }
</style>
</head>
<body>
<h1>魔法生体・解放競技会</h1>
<div id="settings-bar">
<label for="diff-select">難易度:</label>
<select id="diff-select" onchange="resetGame()">
<option value="easy">イージー(のんびりAI)</option>
<option value="normal" selected>ノーマル(標準AI)</option>
<option value="hard">ハード(賢く狙うAI)</option>
</select>
<button onclick="resetGame()" class="setting-btn">やり直す</button>
<button id="bgm-btn" onclick="toggleBGM()">BGM: OFF</button>
</div>
<div id="commentary-box">実況「さあ、本日のメインイベントが始まります!魔法生物を解き放つのはどちらの選手か!?」</div>
<div class="game-container">
<div class="board-wrapper">
<div class="board-title">自軍フィールド</div>
<div id="player-board" class="board"></div>
</div>
<div class="board-wrapper">
<div class="board-title">敵軍フィールド</div>
<div id="enemy-board" class="board"></div>
<div id="status-info"></div>
</div>
</div>
<div id="controls">
<button id="btn-normal" onclick="setMode('normal')" disabled>通常解除</button>
<button id="btn-sp1" class="sp-btn" onclick="setMode('sp1')">【1】マナ・ソナー</button>
<button id="btn-sp2" class="sp-btn" onclick="setMode('sp2')">【2】ツイン・スパーク</button>
<button id="btn-sp3" class="sp-btn" onclick="setMode('sp3')">【3】トライ・バースト</button>
<button id="btn-sp4" class="sp-btn" onclick="setMode('sp4')">【4】ホーリー・クロス</button>
<button id="btn-sp5" class="sp-btn" onclick="setMode('sp5')">【5】グランド・メテオ</button>
</div>
<div style="margin-top: 20px;">
<button id="retry-btn" onclick="resetGame()">もう一度試合をする</button>
</div>
<script>
// --- オーディオシステム (Web Audio API) ---
let audioCtx;
let isPlayingBGM = false;
let bgmInterval;
// 魔法世界風のマイナーコードアルペジオ (Aマイナー)
const melody = [220.00, 261.63, 329.63, 440.00, 329.63, 261.63, 220.00, 164.81];
let noteIdx = 0;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function toggleBGM() {
initAudio();
const btn = document.getElementById('bgm-btn');
if (isPlayingBGM) {
clearInterval(bgmInterval);
isPlayingBGM = false;
btn.innerText = "BGM: OFF";
} else {
isPlayingBGM = true;
btn.innerText = "BGM: ON";
bgmInterval = setInterval(() => {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle'; // 柔らかいレトロ音
osc.frequency.value = melody[noteIdx];
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
noteIdx = (noteIdx + 1) % melody.length;
}, 250); // テンポ
}
}
function playSE(type) {
initAudio();
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (type === 'hit') {
osc.type = 'square';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.15);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.start(now);
osc.stop(now + 0.15);
} else if (type === 'miss') {
osc.type = 'sine';
osc.frequency.setValueAtTime(300, now);
osc.frequency.exponentialRampToValueAtTime(100, now + 0.2);
gain.gain.setValueAtTime(0.1, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
} else if (type === 'freed') {
osc.type = 'square';
osc.frequency.setValueAtTime(523.25, now); // C5
osc.frequency.setValueAtTime(659.25, now + 0.1); // E5
osc.frequency.setValueAtTime(783.99, now + 0.2); // G5
gain.gain.setValueAtTime(0.05, now);
gain.gain.linearRampToValueAtTime(0, now + 0.4);
osc.start(now);
osc.stop(now + 0.4);
} else if (type === 'special') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(600, now);
osc.frequency.linearRampToValueAtTime(1200, now + 0.1);
osc.frequency.linearRampToValueAtTime(600, now + 0.2);
gain.gain.setValueAtTime(0.05, now);
gain.gain.linearRampToValueAtTime(0, now + 0.3);
osc.start(now);
osc.stop(now + 0.3);
}
}
// --- オーディオシステム ここまで ---
const SIZE = 10;
const SHIPS_DATA = [
{ id: 1, size: 1, name: "プチワーム", emoji: "🐛" },
{ id: 2, size: 2, name: "デュアルスネーク", emoji: "🐍" },
{ id: 3, size: 3, name: "トリプルサーペント", emoji: "🦕" },
{ id: 4, size: 4, name: "テトラドラゴン", emoji: "🐉" },
{ id: 5, size: 5, name: "ギガントウィルム", emoji: "🐲" }
];
let playerGrid, enemyGrid, playerHits, enemyHits;
let pHealth = {}, eHealth = {};
let spUsed = {}, eSpUsed = {};
let currentMode = 'normal';
let multiTargets = [];
let isPlayerTurn = true;
let gameOver = false;
const MSGS = {
hit: ["直撃!マナの供給路を遮断しました!", "捉えた!見事な命中精度です!", "バチバチッ!魔法装置が火花を散らしています!"],
miss: ["おっと、ここは空振りだ!", "何もいない!座標がわずかにズレたか!?", "不発!相手の読みが勝りました!"],
freed: ["完全解放!!魔法生物が本来の姿で喜んでいます!", "勝負あり!一体の生物が自由を勝ち取りました!"],
cpu_hit: ["痛恨!こちらの制御が一つ破壊されました!", "敵もさるもの!正確に急所を突いてきます!"],
cpu_miss: ["セーフ!敵の攻撃は明後日の方向です!", "スカッ!今のは肝を冷やしましたね!"],
cpu_freed: ["あぁっ!", "なんということだ!", "痛恨の極み!"]
};
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
function say(txt, isEnemySp = false) {
let box = document.getElementById('commentary-box');
box.innerText = "実況「" + txt + "」";
if (isEnemySp) box.classList.add('enemy-action');
else box.classList.remove('enemy-action');
}
function resetGame() {
playerGrid = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
enemyGrid = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
playerHits = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
enemyHits = Array.from({length: SIZE}, () => Array(SIZE).fill(0));
pHealth = {}; eHealth = {};
spUsed = { sp1: false, sp2: false, sp3: false, sp4: false, sp5: false };
eSpUsed = { sp1: false, sp2: false, sp3: false, sp4: false, sp5: false };
currentMode = 'normal';
multiTargets = [];
isPlayerTurn = true;
gameOver = false;
document.getElementById('retry-btn').style.display = "none";
placeShips(playerGrid, pHealth);
placeShips(enemyGrid, eHealth);
setMode('normal');
render();
updateBtns();
let diffText = document.getElementById('diff-select').options[document.getElementById('diff-select').selectedIndex].text;
say(`新しい試合の始まりです!難易度【${diffText}】で挑みます!`);
}
function placeShips(grid, health) {
SHIPS_DATA.forEach(ship => {
let ok = false;
while(!ok) {
let horiz = Math.random() < 0.5;
let r = Math.floor(Math.random() * SIZE), c = Math.floor(Math.random() * SIZE);
if (canPlace(grid, r, c, ship.size, horiz)) {
for(let i=0; i<ship.size; i++) {
if(horiz) grid[r][c+i] = ship.id; else grid[r+i][c] = ship.id;
}
health[ship.id] = ship.size;
ok = true;
}
}
});
}
function canPlace(grid, r, c, len, horiz) {
if (horiz) {
if (c+len > SIZE) return false;
for(let i=0; i<len; i++) if(grid[r][c+i] !== 0) return false;
} else {
if (r+len > SIZE) return false;
for(let i=0; i<len; i++) if(grid[r+i][c] !== 0) return false;
}
return true;
}
function render() {
const pB = document.getElementById('player-board');
const eB = document.getElementById('enemy-board');
pB.innerHTML = ''; eB.innerHTML = '';
for(let r=0; r<SIZE; r++) {
for(let c=0; c<SIZE; c++) {
let pCell = document.createElement('div');
pCell.className = 'cell';
let sId = playerGrid[r][c];
if(sId > 0) {
let ship = SHIPS_DATA.find(s=>s.id===sId);
if(enemyHits[r][c] === 1) {
if(pHealth[sId] === 0) {
pCell.classList.add('freed'); pCell.innerText = ship.emoji;
} else {
pCell.classList.add('hit'); pCell.innerText = '💥';
}
} else if(enemyHits[r][c] === 3) {
pCell.classList.add('radar-hit'); pCell.innerText = ship.emoji;
} else {
pCell.classList.add('ship'); pCell.innerText = ship.emoji;
}
} else if(enemyHits[r][c] === 2) {
pCell.classList.add('miss'); pCell.innerText = '💧';
}
pB.appendChild(pCell);
let eCell = document.createElement('div');
eCell.className = 'cell';
eCell.dataset.r = r; eCell.dataset.c = c;
let eSId = enemyGrid[r][c];
if(playerHits[r][c] === 1) {
if(eHealth[eSId] === 0) {
eCell.classList.add('freed'); eCell.innerText = SHIPS_DATA.find(s=>s.id===eSId).emoji;
} else {
eCell.classList.add('hit'); eCell.innerText = '💥';
}
} else if(playerHits[r][c] === 2) {
eCell.classList.add('miss'); eCell.innerText = '💧';
} else if(playerHits[r][c] === 3) {
eCell.classList.add('radar-hit'); eCell.innerText = '✨';
}
eCell.onclick = () => { initAudio(); handleClick(r, c); };
eCell.onmouseover = () => handleHover(r, c, true);
eCell.onmouseout = () => handleHover(r, c, false);
eB.appendChild(eCell);
}
}
}
function handleHover(r, c, isOver) {
if (!isPlayerTurn || gameOver) return;
getArea(r, c, currentMode).forEach(t => {
let target = document.querySelector(`.board#enemy-board .cell[data-r='${t.r}'][data-c='${t.c}']`);
if(target) {
if(isOver) target.classList.add('preview-area');
else target.classList.remove('preview-area');
}
});
}
function getArea(r, c, mode) {
if(mode === 'sp1') return [{r,c}, {r,c:c+1}, {r:r+1,c}, {r:r+1,c:c+1}];
if(mode === 'sp4') return [{r,c}, {r:r-1,c}, {r:r+1,c}, {r,c:c-1}, {r,c:c+1}];
if(mode === 'sp5') {
let res = [];
for(let i=0; i<2; i++) for(let j=0; j<3; j++) res.push({r:r+i, c:c+j});
return res;
}
return [{r,c}];
}
function setMode(m) {
currentMode = m;
multiTargets = [];
document.querySelectorAll('.cell').forEach(el => el.classList.remove('selected-mark'));
document.getElementById('btn-normal').disabled = (m === 'normal');
let guide = {
normal: "狙いを定めて、マスをクリックしてください!",
sp1: "左上のマスを選んでスキャン開始!(2x2)",
sp2: "2つのターゲットを選択してください!",
sp3: "3つのターゲットを選択してください!",
sp4: "中心を選んで十字に一斉攻撃!",
sp5: "左上のマスを選んで絨毯爆撃!(3x2)"
};
document.getElementById('status-info').innerText = guide[m];
}
function handleClick(r, c) {
if(!isPlayerTurn || gameOver || (playerHits[r][c] > 0 && playerHits[r][c] < 3)) return;
if (currentMode === 'normal') {
attack([{r, c}]);
} else if (currentMode === 'sp1') {
spUsed.sp1 = true;
playSE('special');
let found = false;
getArea(r,c,'sp1').forEach(t => {
if(t.r<SIZE && t.c<SIZE && enemyGrid[t.r][t.c] > 0 && playerHits[t.r][t.c] === 0) {
playerHits[t.r][t.c] = 3; found = true;
}
});
say(found ? "ソナーに反応あり!魔法生物の生体エネルギーを感知しました!" : "ソナー、反応なし。ここは静かな海域のようです。");
endTurn();
} else if (currentMode === 'sp2' || currentMode === 'sp3') {
let limit = currentMode === 'sp2' ? 2 : 3;
if(multiTargets.some(t => t.r === r && t.c === c)) return;
multiTargets.push({r, c});
playSE('hit'); // 選択音代わり
document.querySelector(`.board#enemy-board .cell[data-r='${r}'][data-c='${c}']`).classList.add('selected-mark');
if(multiTargets.length === limit) {
spUsed[currentMode] = true;
playSE('special');
setTimeout(() => attack(multiTargets), 300);
}
} else if (currentMode === 'sp4' || currentMode === 'sp5') {
spUsed[currentMode] = true;
playSE('special');
setTimeout(() => attack(getArea(r,c,currentMode)), 300);
}
}
function attack(targets) {
let hits = 0;
let anyFreed = false;
targets.forEach(t => {
if(t.r >= SIZE || t.c >= SIZE || t.r < 0 || t.c < 0) return;
if(playerHits[t.r][t.c] === 1 || playerHits[t.r][t.c] === 2) return;
let sId = enemyGrid[t.r][t.c];
if(sId > 0) {
playerHits[t.r][t.c] = 1;
eHealth[sId]--;
hits++;
if(eHealth[sId] === 0) {
let ship = SHIPS_DATA.find(s=>s.id===sId);
say(pick(MSGS.freed) + ` 【${ship.name}】が自由に!`);
anyFreed = true;
}
} else {
playerHits[t.r][t.c] = 2;
}
});
if(anyFreed) {
playSE('freed');
} else if(hits > 0) {
playSE('hit');
if(!gameOver) say(pick(MSGS.hit));
} else {
playSE('miss');
if(!gameOver) say(pick(MSGS.miss));
}
endTurn();
}
function endTurn() {
render();
updateBtns();
check();
if(!gameOver) {
isPlayerTurn = false;
document.getElementById('status-info').innerText = "相手のターンです...";
setTimeout(cpu, 1500);
}
}
function cpu() {
if(gameOver) return;
let diff = document.getElementById('diff-select').value;
let availableSps = [1,2,3,4,5].filter(i => !eSpUsed['sp'+i] && eHealth[i] > 0);
// 難易度による特殊攻撃の確率設定
let spProb = diff === 'hard' ? 0.6 : (diff === 'normal' ? 0.35 : 0.15);
if (availableSps.length > 0 && Math.random() < spProb) {
let spNum = pick(availableSps);
eSpUsed['sp'+spNum] = true;
let spNames = {1:"マナ・ソナー", 2:"ツイン・スパーク", 3:"トライ・バースト", 4:"ホーリー・クロス", 5:"グランド・メテオ"};
let spName = spNames[spNum];
let targets = [];
if (spNum === 1) {
let r = Math.floor(Math.random()*(SIZE-1));
let c = Math.floor(Math.random()*(SIZE-1));
let found = false;
getArea(r, c, 'sp1').forEach(t => {
if(t.r<SIZE && t.c<SIZE && playerGrid[t.r][t.c] > 0 && enemyHits[t.r][t.c] === 0) {
enemyHits[t.r][t.c] = 3; found = true;
}
});
playSE('special');
say(`敵が【${spName}】を発動! ` + (found ? "こちらの生体が感知されてしまった!" : "しかし何も感知されなかったようだ!"), true);
finishCpuTurn();
return;
} else if (spNum === 2 || spNum === 3) {
let limit = spNum === 2 ? 2 : 3;
let validCells = [];
for(let i=0; i<SIZE; i++) for(let j=0; j<SIZE; j++) if(enemyHits[i][j]===0 || enemyHits[i][j]===3) validCells.push({r:i, c:j});
for(let i=0; i<limit; i++) {
if(validCells.length > 0) {
let idx = Math.floor(Math.random() * validCells.length);
targets.push(validCells[idx]);
validCells.splice(idx, 1);
}
}
} else {
let r = Math.floor(Math.random()*SIZE);
let c = Math.floor(Math.random()*SIZE);
targets = getArea(r, c, 'sp'+spNum);
}
say(`敵が特殊攻撃【${spName}】を発動してきます!!`, true);
playSE('special');
setTimeout(() => cpuExecute(targets, true), 1000);
return;
}
let r, c;
if (diff === 'hard' && r === undefined) {
for(let i=0; i<SIZE; i++) {
for(let j=0; j<SIZE; j++) {
if(enemyHits[i][j] === 3) { r = i; c = j; break; }
}
if (r !== undefined) break;
}
}
if(diff === 'hard' && r === undefined) {
let targets = [];
for(let i=0; i<SIZE; i++) {
for(let j=0; j<SIZE; j++) {
if(enemyHits[i][j] === 1 && pHealth[playerGrid[i][j]] > 0) {
[[i-1,j],[i+1,j],[i,j-1],[i,j+1]].forEach(([ar,ac]) => {
if(ar>=0 && ar<SIZE && ac>=0 && ac<SIZE && (enemyHits[ar][ac] === 0 || enemyHits[ar][ac] === 3)) {
targets.push({r:ar, c:ac});
}
});
}
}
}
if(targets.length > 0) { let t = pick(targets); r = t.r; c = t.c; }
}
if (r === undefined && diff === 'easy' && Math.random() < 0.3) {
let misses = [];
for(let i=0; i<SIZE; i++) {
for(let j=0; j<SIZE; j++) {
if((enemyHits[i][j] === 0 || enemyHits[i][j] === 3) && playerGrid[i][j] === 0) misses.push({r:i, c:j});
}
}
if(misses.length > 0) { let t = pick(misses); r = t.r; c = t.c; }
}
if (r === undefined) {
let options = [];
for(let i=0; i<SIZE; i++) {
for(let j=0; j<SIZE; j++) {
if(enemyHits[i][j] === 0 || enemyHits[i][j] === 3) options.push({r:i, c:j});
}
}
if(options.length > 0) { let t = pick(options); r = t.r; c = t.c; }
}
cpuExecute([{r, c}], false);
}
function cpuExecute(targets, isSpecial) {
let hits = 0;
let freedShips = [];
targets.forEach(t => {
if(t.r < 0 || t.r >= SIZE || t.c < 0 || t.c >= SIZE) return;
if(enemyHits[t.r][t.c] === 1 || enemyHits[t.r][t.c] === 2) return;
let sId = playerGrid[t.r][t.c];
if(sId > 0) {
enemyHits[t.r][t.c] = 1;
pHealth[sId]--;
hits++;
if (pHealth[sId] === 0) {
freedShips.push(SHIPS_DATA.find(s=>s.id===sId).name);
}
} else {
enemyHits[t.r][t.c] = 2;
}
});
if (freedShips.length > 0) {
playSE('freed');
let prefix = isSpecial ? "特殊攻撃の直撃だ!" : pick(MSGS.cpu_freed);
say(prefix + ` 自軍の【${freedShips.join('、')}】が解放されてしまった!`, isSpecial);
} else if (hits > 0) {
playSE('hit');
let prefix = isSpecial ? "特殊攻撃が命中!" : pick(MSGS.cpu_hit);
say(prefix + " 被害が出ています!", isSpecial);
} else {
playSE('miss');
let prefix = isSpecial ? "激しい特殊攻撃でしたが、" : "";
say(prefix + pick(MSGS.cpu_miss), isSpecial);
}
finishCpuTurn();
}
function finishCpuTurn() {
render();
updateBtns();
check();
if(!gameOver) {
isPlayerTurn = true;
setMode('normal');
}
}
function check() {
if(Object.values(eHealth).every(v => v === 0)) {
playSE('freed');
say("全生物の解放を確認!あなたの完全勝利です!おめでとう!");
finish();
} else if(Object.values(pHealth).every(v => v === 0)) {
say("自軍の生物がすべて先に解放されました...。残念ながら敗北です。");
finish();
}
}
function finish() {
gameOver = true;
document.getElementById('retry-btn').style.display = "inline-block";
document.getElementById('status-info').innerText = "試合終了!";
}
function updateBtns() {
for(let i=1; i<=5; i++) {
let btn = document.getElementById('btn-sp' + i);
btn.disabled = spUsed['sp' + i] || pHealth[i] === 0;
}
}
resetGame();
</script>
</body>
</html>


■ 今日の学び

今回の “ちょびっと魔法” の世界観は、
#26 ドミノドラゴン や #48 魔法の投網 とつながっています。

この “ちょびっと魔法” は、ファンタジー世界の中で
電気のように道具へエネルギーを伝える便利な魔力 というイメージです。

そして今回もう一つの要素になっている “実況” は、
#18 アメンボ実況レース から生まれたアイデアでした。

ゲーム制作では、AIとのやり取りの中で
ちょっとした「気づき」がありました。

ここ最近は、AIへ相談するときに
ざっくりとした言葉でお願いすることが多かったのですが、

今回は最初から「こういうゲームにしたい!」というイメージが
自分の中でしっかり固まっていました。

そこで、最初の相談の時点で
大事なポイントがブレないように、
細かい設定をしっかり文章にして伝えてみた のです。

すると…… 効果はバツグンでした! ( ✧Д✧) カッ!!

AIが最初に「魔法の世界観」や「ゲームの基本ルール」という
一番大切な土台を深く理解してくれたおかげで、
その後の「ここを直して」「敵も特殊攻撃を使えるようにして」
などの追加調整が、今まで以上にスムーズに進みました。

家を建てるのと同じで、
最初の“土台作り”のコミュニケーションって本当に大事なんだなぁ と
実感しました。( ˘⊖˘) 。o(なるほど…)

これからも、ざっくりアイデアを出してもらう時と、
今回のように細かく指定して作り込む時と、

うまく使い分けながらゲーム実験室を楽しんでいきたいと思います!

■ 次回は、平和な競技のゲームから一転。

恐怖の虫たちの行進が迫ってきます。

全力スリッパで対抗します。

次回もぜひ遊びに来てくださいね。(*^v^)ノシ

▶ 次の実験はこちら  
  (迫る害虫たち)
 「オノマトペ・スリッパアクション」



 ✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)