■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」ボタンを押すと、懐かしいピコピコ音のメロディが流れます♪
どちらが先にすべての幻獣を自由にできるのか……
ぜひ、盤上の熱いバトルを楽しんでみてくださいね。(^^*)
“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。
▼ここからコード▼(クリックで開く)
<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^*)






🌈はじめての方へ
🌳 実験一覧
🌱 このブログについて
🍃 プライバシーポリシー
🥚未記事ゲーム展示室①
🐣未記事ゲーム展示室②