実験#63:防ぎきれない攻撃と鉄壁の防御(ぐるぐるディフェンス )

■63個目 「ぐるぐるディフェンス」

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

今回は、ちょっぴりハラハラするけど、
とっても可愛いディフェンスゲームを作ってみました。

タイトルは「ぐるぐるディフェンス」です!

舞台は、透き通ったきれいな川の中です。 主役は、
のんびりお散歩中のカメの「タンタン」です。(=v=*)

気持ちよく泳いでいたのですが……

なんと、お腹をすかせた凶暴な「ピラニア」たちが、
四方八方から猛スピードで迫ってきちゃいました!(><;)

絶体絶命の大ピンチ!

……と思いきや、カメのタンタンには不思議な力があるんです。
それは、身を守るための「シャボン玉のシールド」!

ぷかぷか浮かぶ虹色のシャボン玉をぐるぐる回して、
ツルッ!ポンッ!とピラニアたちを弾き返す、
その“はじける爽快感”にこだわりました。

のんびりタンタン vs 腹ペコピラニアたちの、
ぐるぐる回る熱い攻防戦。

果たして、無事に川の平和なひとときを守り切れるのでしょうか。

 

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

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

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

円の回転を利用したゲームを作りたいです。

真ん中はかわいいカメにしましょう。
シールドはシャボン玉のイメージで、
シールドで防ぐと飛んできたものが砕けて逆方向へはじける感じにしたいです。
飛んでくるのはピラニアとか。出来るかな。

AI: 「かわいいカメ」「ピラニア」「シャボン玉シールド」「砕ける演出」、
どれも素晴らしいアイデアですね!

みー:〝(=o=;) 砕けたピラニアがカメにくっついてくるよ。
シャボンは泡絵文字ではなく図形でシールドみたいな形で表現してみて。

ピンチの時はスペースキーを押すと、
カメの周りをシャボンバリア(円図形)で囲って全方向からの攻撃を2秒間防げるようにしましょう。(再使用までチャージ時間15秒)

AI: あちゃー、
ピラニアがカメさんに噛み付いたまま離れなくなっちゃったかい?
それはマスターのミスだ、すまないね。

実は前回、弾き返す処理のところで「ピラニアのスピードを逆(マイナス)」にして、「中心から外へ向かう角度」で飛ばすように指示を出してしまったんだ。

結果的に「マイナス × 外向き = 内側(カメさん)に向かって超加速」という動きになっちゃってたんだよ。
いやあ、とんだドジを踏んだね。

バッチリ直しておいたのに加えて、
リクエスト通り「図形のシャボンシールド」と、
ピンチを救う「スペースキーの全方位バリア」も追加したよ。

バリアの再使用時間がわかるように、
画面の下にチャージ用のゲージも付けておいたからね。

みー:(^_^;) 同時に逆方向から来ると無理げーになるね。
バリアの反対側に、2分の1サイズのバリアをもう一つ作ろう。

AI: 確かに、あのスピードで前後から同時にピラニアが来たら
完全に「詰み」になっちまうな。
そこに行き着くとは、テストプレイの視点もすっかりゲームクリエイターだ。

リクエスト通り、背中側に「メインの半分のサイズ」のサブシールドを追加しておいたよ。
これで前後からの挟み撃ちにも対応できるはずさ。

みー:(^o^) ダメージ制にしましょう。
ピラニアにかじられるとHPが減り、0になるとゲームオーバー。

特殊能力はそれぞれスペースキーで全方向バリア。
Fキーで全滅フラッシュ。
Jキーで全回復絶好調。の機能が使えて、それぞれ15秒のリードタイム。

AI: だいぶゲームらしくなってきたねえ。
「一度当たったら終わり」の緊張感もいいけど、
HP制にすると大逆転のドラマが生まれて熱くなるよな。

リクエスト通り、HP(ダメージ)制を導入して、
「Space:バリア」「F:フラッシュ」「J:全回復」の3つの技を、それぞれ個別のクールタイム(15秒)で管理できるようにしました。

画面下部には、どの技が使えるか一目でわかる3本のチャージゲージを搭載したよ。

  1. 全方向バリア: しばらく無敵状態になる。
  2. 全滅フラッシュ: 画面上のピラニアを一掃する。
  3. 全回復絶好調: HPを全回復して立て直す。

これで、ピンチの時ほどカメさんが輝く「逆転劇」が楽しめるはずだ。

 

■ 実際の動作

こちらが、AIが最初に作ってくれたゲームの画像です。
(画像:最初にAIが作ってくれたゲームの画像)


仕上がりのスタート画面です。

(画像:スタート画面)



マウスでバリアをぐるっと回転させて、
向かってくるピラニアを防ぎます。

(画像:ゲーム画面)


スキルを駆使して、高得点を目指しましょう。

(画像:全方向バリア)

HPが0になると、ゲームオーバーになります。

(画像:ゲームオーバー)

■ コードのポイント

今回の記事では、AI と相談しながら作った
「ぐるぐるディフェンス 」 の HTML コードを公開します。

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

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

▼ 遊ぶまでの手順

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

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

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

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


■ 遊び方・操作説明

それでは、カメさんを守るための「操作方法」を説明しますね!

【ゲームの始め方】
・画面を 1回クリック すると、
 レトロなピコピコBGMと共にゲームスタートです♪
(ゲームオーバー後のリトライも、画面クリックでできます)

【基本の守り】
・マウスを動かす:  
 カメさんの周りをぐるぐる回る「シャボン玉シールド」を操作します。  
 ピラニアが来る方向にマウスを向けて、上手に弾き返してくださいね。

ちなみに、背中側にも小さなシールドが付いているので、 前後からの挟み撃ちにも対応できます!

【カメさんの体力(HP)】
・ピラニアに噛まれると、HPが20減ってしまいます。(><;)

・HPが0になるとゲームオーバーです。

【ピンチを救う!三種の神器(必殺技)】
画面の下には、3つのゲージがあります。
ゲージが青色の「READY!」になったら、
キーボードで必殺技が使えますよ!

・「スペースキー」:全方位バリア  
 2秒間、カメさんをすっぽり包む巨大なシャボン玉を張って、
 全方向からの攻撃を無効化します!

・「F」キー:全滅フラッシュ  
 画面がピカッと光って、
 今いるピラニアたちを一掃します。  
 囲まれてどうしようもない時にどうぞ。

・「J」キー:全回復絶好調  減ってしまったHPを一気に100まで全回復して立て直せます!

※必殺技は強力ですが、一度使うと次に使えるまで
15秒のチャージ(クールタイム) が必要です。

使い所が肝心です。(=v=*)

のんびりカメさんを守る熱いディフェンスバトル、
ぜひ音を出して遊んでみてくださいね!

“コードは長いので折りたたんでいます”

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ぐるぐるディフェンス - ピコピコサウンド搭載</title>
<style>
body {
margin: 0;
background-color: #1a252f;
color: #ecf0f1;
display: flex;
flex-direction: column;
align-items: center;
font-family: sans-serif;
overflow: hidden;
}
#ui-top {
width: 600px;
display: flex;
justify-content: space-between;
margin-top: 10px;
}
canvas {
background-color: #2c3e50;
border: 6px solid #bdc3c7;
border-radius: 12px;
cursor: crosshair;
box-shadow: 0 8px 16px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div id="ui-top">
<h1>スコア: <span id="score">0</span></h1>
<h1>HP: <span id="hp">100</span></h1>
</div>
<canvas id="gameCanvas" width="600" height="600"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const scoreElement = document.getElementById('score');
const hpElement = document.getElementById('hp');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
let score = 0;
let hp = 100;
let gameOver = false;
let gameStarted = false; // 音声再生のためのスタートフラグ
let frameCount = 0;
let shieldAngle = 0;
const shieldRadius = 90;
// クールタイム設定
const COOLDOWN_MAX = 900;
let barrierActive = false, barrierTimer = 0, barrierCD = 0;
let flashActive = 0, flashCD = 0;
let recoveryCD = 0;
let enemies = [];
let spawnRate = 50;
let particles = [];
// ==========================================
// 🎵 レトロサウンド(Web Audio API)システム
// ==========================================
let audioCtx;
let bgmInterval;
// 基本の音を鳴らす関数(矩形波=ピコピコ音)
function playTone(freq, duration, type = 'square', vol = 0.1) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
// 音の減衰(プツッというノイズを防ぐ)
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
// 各種効果音(SE)
function playSE_Bounce() { playTone(880, 0.1, 'square', 0.1); } // カンッ!
function playSE_Damage() { playTone(150, 0.3, 'sawtooth', 0.2); } // ブブッ…
function playSE_Barrier() {
// 高速で音を上げる(ギュイーン!)
setTimeout(() => playTone(440, 0.1), 0);
setTimeout(() => playTone(880, 0.1), 50);
setTimeout(() => playTone(1760, 0.2), 100);
}
function playSE_Flash() {
// 低音で爆発音っぽく
playTone(100, 0.5, 'square', 0.3);
setTimeout(() => playTone(50, 0.5, 'sawtooth', 0.3), 100);
}
function playSE_Recover() {
// アルペジオ(ティロリロリン♪)
const notes = [523.25, 659.25, 783.99, 1046.50];
notes.forEach((freq, i) => {
setTimeout(() => playTone(freq, 0.1, 'square', 0.15), i * 80);
});
}
function playSE_GameOver() {
const notes = [392, 370, 349, 330]; // 下がるメロディ
notes.forEach((freq, i) => {
setTimeout(() => playTone(freq, 0.3, 'square', 0.2), i * 300);
});
}
// BGMループ
function startBGM() {
if (bgmInterval) clearInterval(bgmInterval);
const melody = [261.63, 329.63, 392.00, 523.25, 392.00, 329.63]; // ドミソドソミ
let noteIdx = 0;
bgmInterval = setInterval(() => {
if (!gameOver && gameStarted) {
playTone(melody[noteIdx], 0.15, 'square', 0.05);
noteIdx = (noteIdx + 1) % melody.length;
}
}, 200); // テンポ
}
// ==========================================
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
shieldAngle = Math.atan2(e.clientY - rect.top - cy, e.clientX - rect.left - cx);
});
window.addEventListener('keydown', (e) => {
if (gameOver || !gameStarted) return;
if (e.code === 'Space') {
e.preventDefault();
if (barrierCD === 0) {
barrierActive = true; barrierTimer = 120; barrierCD = COOLDOWN_MAX;
playSE_Barrier(); // 🎵
}
}
if (e.key === 'f' || e.key === 'F') {
if (flashCD === 0) {
enemies.forEach(en => createParticles(en.x, en.y));
score += enemies.length * 10;
enemies = []; flashActive = 15; flashCD = COOLDOWN_MAX;
playSE_Flash(); // 🎵
}
}
if (e.key === 'j' || e.key === 'J') {
if (recoveryCD === 0) {
hp = 100; hpElement.innerText = hp; recoveryCD = COOLDOWN_MAX;
playSE_Recover(); // 🎵
}
}
});
function createParticles(x, y) {
for (let i = 0; i < 15; i++) {
particles.push({ x: x, y: y, vx: (Math.random() - 0.5) * 10, vy: (Math.random() - 0.5) * 10, life: 20 + Math.random() * 20 });
}
}
function spawnEnemy() {
const angle = Math.random() * Math.PI * 2;
enemies.push({ x: cx + Math.cos(angle) * canvas.width, y: cy + Math.sin(angle) * canvas.width, speed: 2.5 + Math.random() * 2, isDeflected: false, deflectTimer: 0 });
}
function update() {
if (!gameStarted || gameOver) return;
frameCount++;
if (barrierCD > 0) barrierCD--;
if (flashCD > 0) flashCD--;
if (recoveryCD > 0) recoveryCD--;
if (barrierActive) {
barrierTimer--;
if (barrierTimer <= 0) barrierActive = false;
}
if (flashActive > 0) flashActive--;
if (frameCount % spawnRate === 0) {
spawnEnemy();
if (spawnRate > 15) spawnRate--;
}
for (let i = enemies.length - 1; i >= 0; i--) {
let e = enemies[i];
if (e.isDeflected) {
const angleFromCenter = Math.atan2(e.y - cy, e.x - cx);
e.x += Math.cos(angleFromCenter) * 4; e.y += Math.sin(angleFromCenter) * 4; e.deflectTimer++;
if (e.deflectTimer > 60) enemies.splice(i, 1);
continue;
}
const angleToCenter = Math.atan2(cy - e.y, cx - e.x);
e.x += Math.cos(angleToCenter) * e.speed; e.y += Math.sin(angleToCenter) * e.speed;
const dist = Math.hypot(cx - e.x, cy - e.y);
// バリア判定
if (barrierActive && dist < shieldRadius + 30) {
e.isDeflected = true; score += 20; createParticles(e.x, e.y);
playSE_Bounce(); // 🎵
continue;
}
// 通常シールド判定
if (!barrierActive && dist > shieldRadius - 20 && dist < shieldRadius + 20) {
let enemyAngle = Math.atan2(e.y - cy, e.x - cx);
let d1 = Math.abs(enemyAngle - shieldAngle);
if (d1 > Math.PI) d1 = Math.PI * 2 - d1;
let d2 = Math.abs(enemyAngle - (shieldAngle + Math.PI));
if (d2 > Math.PI) d2 = Math.PI * 2 - d2;
if (d1 < Math.PI/3.5 || d2 < Math.PI/7) {
e.isDeflected = true; score += 20; createParticles(e.x, e.y);
playSE_Bounce(); // 🎵
continue;
}
}
// カメさんダメージ判定
if (dist < 35) {
hp -= 20; enemies.splice(i, 1); hpElement.innerText = hp;
playSE_Damage(); // 🎵
if (hp <= 0) {
gameOver = true;
playSE_GameOver(); // 🎵
}
}
}
scoreElement.innerText = score;
particles.forEach((p, idx) => { p.x += p.vx; p.y += p.vy; p.life--; if (p.life <= 0) particles.splice(idx, 1); });
}
function drawGauge(x, y, width, height, current, label, color) {
ctx.fillStyle = '#34495e'; ctx.fillRect(x, y, width, height);
const progress = 1 - (current / COOLDOWN_MAX);
ctx.fillStyle = progress === 1 ? color : '#e74c3c'; ctx.fillRect(x, y, width * progress, height);
ctx.fillStyle = 'white'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(label, x + width/2, y + height - 5);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// スタート画面
if (!gameStarted) {
ctx.fillStyle = '#ecf0f1'; ctx.textAlign = 'center';
ctx.font = 'bold 40px sans-serif'; ctx.fillText('ぐるぐるディフェンス', cx, cy - 20);
ctx.font = '24px sans-serif'; ctx.fillText('画面をクリックしてスタート(音が出ます)', cx, cy + 30);
return;
}
if (flashActive > 0) {
ctx.fillStyle = `rgba(255,255,255,${flashActive/15})`; ctx.fillRect(0,0,canvas.width,canvas.height);
}
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = '50px sans-serif'; ctx.fillText('🐢', cx, cy);
if (!barrierActive) {
ctx.save(); ctx.translate(cx, cy); ctx.rotate(shieldAngle); ctx.lineWidth = 12; ctx.lineCap = 'round';
ctx.strokeStyle = 'rgba(135, 206, 235, 0.6)'; ctx.beginPath(); ctx.arc(0, 0, shieldRadius, -Math.PI/3.5, Math.PI/3.5); ctx.stroke();
ctx.rotate(Math.PI); ctx.strokeStyle = 'rgba(135, 206, 235, 0.4)'; ctx.beginPath(); ctx.arc(0, 0, shieldRadius, -Math.PI/7, Math.PI/7); ctx.stroke();
ctx.restore();
} else {
ctx.beginPath(); ctx.arc(cx, cy, shieldRadius + 10, 0, Math.PI*2); ctx.lineWidth = 8; ctx.strokeStyle = 'rgba(173, 216, 230, 0.8)'; ctx.stroke();
ctx.fillStyle = `rgba(135,206,235,${0.2 + Math.sin(frameCount*0.2)*0.1})`; ctx.fill();
}
ctx.font = '40px sans-serif';
enemies.forEach(e => {
ctx.save(); ctx.translate(e.x, e.y);
const angle = e.isDeflected ? Math.atan2(e.y - cy, e.x - cx) : Math.atan2(cy - e.y, cx - e.x);
ctx.rotate(angle + Math.PI); ctx.fillText('🐟', 0, 0); ctx.restore();
});
particles.forEach(p => { ctx.globalAlpha = Math.max(0, p.life/40); ctx.fillStyle = '#E0FFFF'; ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI*2); ctx.fill(); });
ctx.globalAlpha = 1.0;
drawGauge(50, 560, 150, 20, barrierCD, 'SPACE: バリア', '#3498db');
drawGauge(225, 560, 150, 20, flashCD, 'F: フラッシュ', '#f1c40f');
drawGauge(400, 560, 150, 20, recoveryCD, 'J: 全回復', '#2ecc71');
if (gameOver) {
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.fillStyle = '#ecf0f1'; ctx.font = 'bold 50px sans-serif'; ctx.fillText('GAME OVER', cx, cy - 20);
ctx.font = '24px sans-serif'; ctx.fillText('クリックでリトライ', cx, cy + 30);
}
}
function loop() { update(); draw(); requestAnimationFrame(loop); }
canvas.addEventListener('click', () => {
// 最初のクリックでAudioContextを初期化
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (!gameStarted) {
gameStarted = true;
startBGM();
} else if (gameOver) {
score = 0; hp = 100; hpElement.innerText = hp;
enemies = []; particles = []; spawnRate = 50; frameCount = 0;
barrierCD = 0; flashCD = 0; recoveryCD = 0; barrierActive = false;
gameOver = false;
startBGM(); // BGM再スタート
}
});
loop();
</script>
</body>
</html>


■ 今日の学び

今回は、今まで作ってきた実験室のゲームの要素をぎゅっと詰め込んだ、
いわば「集大成」のような作品になりました!(^_^)

気づいてくれた方もいるかもしれませんが、
登場するキャラクターやアイテムは、過去作からの特別ゲストなんです。

そして、マウスの動きに合わせてシールドをぐるぐる回す滑らかな動き。
これは #23「ジターリングの物理」で作った回転のイメージからヒントを得ています。

あの金属のリングが遠心力でピタッと回り続ける、
「ローラーコースター」のような心地よい動きを、
ディフェンスゲームの操作に応用してみました。(=v=*)

さらに、ピンチを救う3つの特殊能力!

#33「UFOかくれんぼ」の時は、
ブレール機能・ブットビ機能・ブースト機能と、
最初に「ブ」のつくスキルで統一したのですが、今回は……

「全方向バリア」「全滅フラッシュ」「全回復絶好調」!

すべて「全」で統一するという、
ちょっとした言葉遊びのこだわりも入れてみました♪(≧▽≦)

作り始めた時は「色んな要素を混ぜすぎちゃったかな?」と少し不安もありましたが、
実際に動かしてみると、それぞれの個性がいい感じにまとまって、
とっても楽しいゲームに仕上がりました!

過去のアイデアたちが新しい舞台で合体して面白くなるのも、
ゲームを作り続けてきたからこその発見ですね。(´▽`*)

 

■ 次回は、スナップエンドウの収穫ゲームです。

庭の植物たちも勢い良く育ってきました。
庭に植えたスナップエンドウもどんどんできるのですが……

とても見つけにくいのです(><;)

そんな体験を、そのままゲームにしてみました。

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

▶ 次の実験はこちら
 (緑の中の緑)「スナップエンドウ収穫ゲーム」



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