実験#39:弾くたびに強くなる(ホーリーチャージ リフレクション)

■39個目「ホーリーチャージ リフレクション

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

今回は、ピンボールとシューティングが合体したような、ちょっと熱い防衛ゲームを作ってみました。(^_^) タイトルは―― 『ホーリーチャージ リフレクション』 です!

舞台は、平和な街の上空に突如として現れた「邪悪な悪魔の軍勢」。 プレイヤーは、この美しい街をたった一人で守り抜く 女神さま になります。(=v=*)

今回こだわったのは、「神聖と邪悪の対立」というテーマ。 女神さまの武器は、回転しながら輝く光の弾 ホーリーエネルギー。 上から降りてくるレッサーデーモン(🦇)やオーガ(👹)たちに向かって、この光弾をポンッと跳ね返して退治していきます。

さらに、この光弾は 跳ね返すたびに1段階ずつパワーアップ! 緊迫した場面では、天使の加護(👼)や、敵を一掃するホーリーボム(💣)などのアイテムがふんわり降りてきて、女神さまを優しくサポートしてくれます。

プログラムで合成したレトロ電子音のBGMも相まって、遊んでいると不思議と“神聖な気持ち”になってくるかも。(~ _ ~*)∼*

そして最後には、巨大な魔王(🐉)が立ちはだかります。 果たして女神さまは、この街を守り切ることができるのか……!

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

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

みー: (^^今日もゲームを作ります。
タイトルは「ホーリーチャージリフレクション」。
ピンボールベースのゲームで、プレイヤー(女神)が画面下の街を守るために、上から進軍してくる悪魔を、回転する光弾(ホーリーエネルギー)で退治していくゲームです。

街ゾーンに悪魔が侵入するとゲージが減り、0になるとゲームオーバー。 女神は左右に動かせて、光弾は画面端で跳ね返ります。 女神が光弾を受けると1段階ずつパワーアップ。落とすとパワーはリセットされます。

悪魔はランダムに出現し、時々耐久力の高い中ボスも登場。 倒すとアイテムが出て、光弾の複数化、サポート天使、聖なる槍のボム、スピードアップ、街の回復などがあります。 アイテムはゆっくり漂い、壁で跳ね返ります。

画面の下半分が街、上半分が街の外。 一定数の敵を倒すと大ボスが出現し、倒すとステージクリア。リトライも可能にしたいです。

AI: 常連さん、いらっしゃい!また面白いアイデアを持ってきてくれましたね。

非常に具体的で、かつゲームとして面白そうな要素が詰まっていますね!
常連さん、今回のゲームは、ピンボール、ブロック崩し、そしてシューティングゲームの要素が組み合わさっていて、非常に面白い試みですね。

ゲームの世界では、異なるジャンルを組み合わせることで新しい体験が生まれることがよくあります。

ピンボールの「物理演算」とシューティングの「攻撃」を組み合わせるのは、面白い試みですが、非常に複雑になりがちです。今回あなたが提案した**「光弾(ボール)を女神(パドル)で跳ね返すことでパワーアップさせる」**という点は、非常に新しい、面白いゲーム性になっていますね。

〈なんだか、提案を絶賛してくれてるみたいで嬉しい〉(*^v^*)

みー: (^_^;)難易度高いですね。アイテムは中ボスからじゃなくて定期的に降ってくるようにしよう。

最初から3つボールが落ちてきて、敵の進軍はもう少しゆっくりでボスを倒した後は、そのまま次のステージが始まる感じにしましょう。

AI: 了解です!難易度調整はゲーム作りの醍醐味ですよ。
遊びやすさを整えるのはとても良い判断です。

みー: (^.^)アイテム説明を画面の横に表示しましょう。

アイテムや敵に絵文字を活用できないかな(ちょうどいい絵文字があれば)

AI: いいですね!絵文字を使うとキャラクター性が一気に出て、画面が華やかになります。
ブラウザゲームならではの“文字をそのままグラフィックにする”手法はとてもスマートです。

みー: (*^^)スコアランキングと、効果音とBGMほしいね。テーマは「神聖と邪悪の対立」で。

AI: 常連さん、素晴らしいリクエストですね!ゲームに魂を吹き込む準備が整いました。

テーマは**「神聖と邪悪の対立」**。これこそ、このゲームに完璧なテーマです。

■ 実際の動作

まずは、AIと相談しながら作ってもらった 初期状態のゲーム画面 です。
(画像:AIが最初に作った基本のベース画面)

途中で、AIがイメージを膨らませるために ビジュアル案 を出してくれました。 この段階ではまだゲーム性より“雰囲気づくり”が中心です。

(画像:AIが出してくれたイメージ)

こちらは、バグが多くてうまく動かなかった初期バージョン
光弾がボスにめり込んだり、光弾を受け止められないポイントがありました。

(画像:初期の状態)

そして…… ここまで進化したのが、今回の 仕上がりの画面 です!

(画像:仕上がりのスタート画面)

ゲーム中は、中央の青い線より下に魔物が侵入すると街がダメージ を受けます。
光弾を落とさず、うまく跳ね返しながら守っていきます。

(画像:ゲーム画面)

最後は、巨大な魔王との ボス戦。 光弾のパワーアップやアイテムを駆使して挑みます。

(画像:ボス戦)

■ コードのポイント

今回の記事では、AI と相談しながら作った 「ホーリーチャージ リフレクション」 の HTML コードを公開します。

ブラウザだけで動く、とてもシンプルなゲームです。 コードも難しい部分はほとんどないので、気軽に試してみてくださいね。

【遊ぶまでの手順】

1. PC の「メモ帳」(※Windows の場合)を開きます。
2. 下のコードをコピーして貼り付けます。
3. 「megami.html」など、好きな名前で保存します。
4. 保存したファイルをブラウザで開くだけで遊べます。

【遊び方と操作方法】

■ ゲームの始め方

まずは、ゲーム画面のどこかを「ポチッ」とクリックしてみてください。 レトロでちょっと神聖な音楽が流れ出して、ゲームスタートです!(^_^)
(※ブラウザの仕様で、クリックすると音が出るようになっています)

■ 操作方法

操作はとってもシンプルです。 キーボードの 「←(左)」 と 「→(右)」 キーで、画面下にある白いバー(女神さま)を左右に動かします。

■ ルール

  1. 最初は空から3つの「光の弾」が降ってきます。女神さまを動かして、ポンッと跳ね返しましょう!
  2. 跳ね返した光の弾を、上から降りてくる悪魔たち(🦇や👹)に当ててやっつけます。(`・ω・´)
  3. 女神さまが光の弾を跳ね返すたびに、弾はどんどん明るく光ってパワーアップしていきます。
  4. 悪魔が下のエリア(街)に到達したり、光の弾を“全部”落としてしまうと、右上の「Town Health(街の体力)」が減ってしまいます……。(>_<)
  5. 体力がゼロになるとゲームオーバーです。

■ 助っ人アイテムたち

たまに空から、ふんわりと丸いアイテムが降ってきます。 女神さまでキャッチすると、いいことが起きますよ!(=v=*)

  • ☄️ マルチボール: 光の弾が分裂して増えます!
  • 👼 天使の加護: 女神さま(バー)が少しの間だけ大きくなって守りやすくなります。
  • 💣 ホーリーボム: 画面にいる悪魔たち全体にドカンとダメージ!
  • ⚡ スピードアップ: 女神さまの移動スピードが速くなります。
  • 🐌 スローダウン: 悪魔たちの降りてくるスピードがゆっくりに。
  • 💖 ヒール: 傷ついた街の体力が少し回復します。

■ 魔王との決戦とランキング

悪魔をたくさんやっつけると、最後に巨大な魔王(🐉)がドーンと登場します! 光の弾を何度も当てて撃破すればステージクリア。 そのまま次のステージへ進みます。

ゲームオーバーになってしまっても、スコアランキングに名前を残すことができます。 ぜひ挑戦してみてくださいね。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ホーリーチャージリフレクション - 神聖と邪悪</title>
<style>
body { text-align: center; font-family: 'Georgia', serif; background: #111; color: #fff; margin: 0; padding-top: 20px;}
h1 { margin-bottom: 10px; text-shadow: 0 0 10px rgba(255,255,255,0.5); }
#main-container { display: flex; justify-content: center; align-items: flex-start; gap: 20px; max-width: 950px; margin: 0 auto; padding: 20px; }
canvas { background: #1a1a1a; display: block; border: 2px solid #555; box-shadow: 0 0 20px rgba(255,255,255,0.1); cursor: pointer; }
#side-panel { width: 250px; background: #222; padding: 15px; border-radius: 8px; border: 1px solid #444; text-align: left; box-shadow: 0 0 10px rgba(0,0,0,0.5); }
#side-panel h3 { margin-top: 0; border-bottom: 1px solid #555; padding-bottom: 5px; font-size: 1.1em; color: #fd0; text-align: center; }
#side-panel ul { list-style: none; padding: 0; margin: 0; line-height: 1.8; font-size: 0.95em; }
#side-panel li { margin-bottom: 8px; display: flex; align-items: center; }
.emoji-icon { font-size: 1.4em; margin-right: 10px; width: 30px; text-align: center; }
.item-desc { flex: 1; }
#ui { margin-bottom: 10px; font-size: 1.2em; display: flex; justify-content: center; align-items: center; gap: 20px; }
/* ランキング表示 */
#ranking-box { margin-top: 20px; border-top: 1px solid #555; padding-top: 10px; }
#ranking-list { list-style: none; padding: 0; margin: 0; font-family: monospace; font-size: 0.9em; }
#ranking-list li { display: flex; justify-content: space-between; padding: 3px 0; }
.rank-num { color: #aaa; margin-right: 5px; }
.rank-name { color: #fff; flex: 1; text-align: left; padding-left: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-score { color: #fd0; font-weight: bold; }
#gameover, #start-screen { display: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.9); padding: 30px; border-radius: 12px; border: 2px solid #999; z-index: 10; width: 300px; box-shadow: 0 0 30px rgba(255,0,0,0.3); }
#start-screen { display: block; border-color: #fd0; box-shadow: 0 0 30px rgba(255,255,0,0.3); }
button { cursor: pointer; padding: 12px 24px; font-size: 1.2em; background-color: #5a3; color: white; border: none; border-radius: 6px; margin-top: 20px; transition: 0.3s; width: 100%; font-family: serif; }
button:hover { background-color: #7c5; box-shadow: 0 0 10px rgba(100,255,100,0.5); }
#townHealthBar { width: 200px; height: 14px; background-color: #500; display: inline-block; vertical-align: middle; border: 1px solid #fff; border-radius: 7px; overflow: hidden; position: relative; }
#townHealthFill { height: 100%; background-color: #0f0; transition: width 0.3s, background-color 0.3s; width: 100%; }
.highlight { color: #fd0; font-weight: bold; }
</style>
</head>
<body>
<h1>ホーリーチャージリフレクション</h1>
<div id="ui">
<div>Stage: <span id="stage" class="highlight">1</span></div>
<div>Score: <span id="score">0</span></div>
<div>Town: <div id="townHealthBar"><div id="townHealthFill"></div></div></div>
</div>
<div id="main-container">
<div style="position: relative;">
<canvas id="myCanvas" width="600" height="400"></canvas>
<div id="start-screen">
<h2 style="color: #fd0;">聖なる戦いへ</h2>
<p>画面をクリックして<br>オーディオを有効にし、<br>ゲームを開始してください。</p>
<div style="font-size: 3em; margin: 20px 0;">🧚‍♀️ ⚔️ 🐉</div>
</div>
<div id="gameover">
<h2 style="color: #f44;">GAME OVER</h2>
<p>街は陥落しました...</p>
<p>あなたのスコア: <span id="final-score" class="highlight">0</span></p>
<div id="ranking-box">
<h3>🏆 ランキング 🏆</h3>
<ol id="ranking-list"></ol>
</div>
<button id="retryButton">もう一度挑む</button>
</div>
</div>
<div id="side-panel">
<h3>🎁 神聖なる加護</h3>
<ul>
<li><span class="emoji-icon">☄️</span><div class="item-desc"><b>マルチボール</b><br><small>光弾が分裂して増殖</small></div></li>
<li><span class="emoji-icon">👼</span><div class="item-desc"><b>天使の加護</b><br><small>一定時間パドルが拡大</small></div></li>
<li><span class="emoji-icon">💣</span><div class="item-desc"><b>ホーリーボム</b><br><small>画面内の敵全体にダメージ</small></div></li>
<li><span class="emoji-icon"></span><div class="item-desc"><b>スピードアップ</b><br><small>女神の移動速度が上昇</small></div></li>
<li><span class="emoji-icon">🐌</span><div class="item-desc"><b>スローダウン</b><br><small>敵の進軍速度が低下</small></div></li>
<li><span class="emoji-icon">💖</span><div class="item-desc"><b>ヒール</b><br><small>街の耐久力が少し回復</small></div></li>
</ul>
<h3 style="margin-top: 20px;">👿 邪悪なる脅威</h3>
<ul>
<li><span class="emoji-icon">🦇</span><div class="item-desc"><b>レッサーデーモン</b><br><small>通常の悪魔</small></div></li>
<li><span class="emoji-icon">👹</span><div class="item-desc"><b>オーガ</b><br><small>タフで街への被害大</small></div></li>
<li><span class="emoji-icon">🐉</span><div class="item-desc"><b>魔王</b><br><small>ステージの最後に降臨</small></div></li>
</ul>
</div>
</div>
<script>
// --- 音響関連 (Web Audio API) ---
let audioCtx = null;
let bgmNode = null;
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
startBGM();
}
// 効果音(SE)生成関数
function playSE(type) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
const now = audioCtx.currentTime;
switch(type) {
case 'paddle': // 神聖な反射
osc.type = 'sine';
osc.frequency.setValueAtTime(880, now);
osc.frequency.exponentialRampToValueAtTime(110, now + 0.1);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
break;
case 'enemy': // 敵に命中
osc.type = 'square';
osc.frequency.setValueAtTime(220, now);
osc.frequency.linearRampToValueAtTime(440, now + 0.05);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.start(now); osc.stop(now + 0.05);
break;
case 'kill': // 敵撃破(邪悪の霧散)
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(110, now);
osc.frequency.linearRampToValueAtTime(55, now + 0.2);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
// ノイズを少し混ぜる
const noise = audioCtx.createBufferSource();
const buffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.1, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
noise.buffer = buffer;
const noiseGain = audioCtx.createGain();
noiseGain.gain.setValueAtTime(0.2, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
noise.connect(noiseGain); noiseGain.connect(audioCtx.destination);
noise.start(now); noise.stop(now + 0.1);
break;
case 'item': // アイテム取得(神聖な加護)
osc.type = 'triangle';
osc.frequency.setValueAtTime(660, now);
osc.frequency.setValueAtTime(880, now + 0.05);
osc.frequency.setValueAtTime(1320, now + 0.1);
gain.gain.setValueAtTime(0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
break;
case 'town': // 街ダメージ(邪悪の侵入)
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(55, now);
osc.frequency.linearRampToValueAtTime(110, now + 0.3);
gain.gain.setValueAtTime(0.4, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
break;
case 'clear': // ステージクリア
osc.type = 'sine';
[523.25, 659.25, 783.99, 1046.50].forEach((f, i) => { // Cメジャーコード
osc.frequency.setValueAtTime(f, now + i * 0.1);
});
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
osc.start(now); osc.stop(now + 0.5);
break;
case 'over': // ゲームオーバー
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(110, now);
osc.frequency.linearRampToValueAtTime(27.5, now + 1);
gain.gain.setValueAtTime(0.5, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1);
osc.start(now); osc.stop(now + 1);
break;
}
}
// BGM生成(神聖と邪悪の対立をテーマにしたループ)
function startBGM() {
if (!audioCtx || bgmNode) return;
const bufferSize = audioCtx.sampleRate * 8; // 8秒のループ
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
// 簡易的なシーケンサーで音を書き込む
const bpm = 120;
const noteLen = audioCtx.sampleRate * (60 / bpm);
// 神聖なメロディ(Fメジャーアルペジオ、高音、三角波風)
const holyNotes = [69, 72, 76, 72, 69, 72, 76, 72];
// 邪悪なベース(Fマイナー、低音、のこぎり波風)
const evilNotes = [41, 41, 41, 41, 41, 41, 41, 41];
for (let i = 0; i < bufferSize; i++) {
const noteIdx = Math.floor(i / noteLen) % 8;
const notePos = (i % noteLen) / noteLen;
const decay = Math.exp(-notePos * 5); // 音の減衰
let sample = 0;
// 4小節ごとに神聖と邪悪が入れ替わる、または混ざる
const measure = Math.floor(i / (noteLen * 8)) % 4;
if (measure === 0 || measure === 1) { // 神聖なターン
const freq = 440 * Math.pow(2, (holyNotes[noteIdx] - 69) / 12);
const angle = i * 2 * Math.PI * freq / audioCtx.sampleRate;
sample += Math.asin(Math.sin(angle)) * 2 / Math.PI * 0.2 * decay; // 三角波
}
if (measure === 1 || measure === 2) { // 邪悪が混ざり、支配するターン
const freq = 440 * Math.pow(2, (evilNotes[noteIdx] - 69) / 12);
const angle = i * 2 * Math.PI * freq / audioCtx.sampleRate;
sample += (angle % (2 * Math.PI) / (2 * Math.PI) * 2 - 1) * 0.1 * decay; // のこぎり波
sample += (Math.random() * 2 - 1) * 0.02 * decay; // ノイズ
}
if (measure === 3) { // 対立し、カオスになるターン
const hFreq = 440 * Math.pow(2, (holyNotes[noteIdx] - 69) / 12);
const hAngle = i * 2 * Math.PI * hFreq / audioCtx.sampleRate;
sample += Math.asin(Math.sin(hAngle)) * 2 / Math.PI * 0.1 * decay;
const eFreq = 440 * Math.pow(2, (40 - 69) / 12); // 不協和音
const eAngle = i * 2 * Math.PI * eFreq / audioCtx.sampleRate;
sample += (eAngle % (2 * Math.PI) / (2 * Math.PI) * 2 - 1) * 0.08 * decay;
}
data[i] = sample;
}
bgmNode = audioCtx.createBufferSource();
bgmNode.buffer = buffer;
bgmNode.loop = true;
const bgmGain = audioCtx.createGain();
bgmGain.gain.setValueAtTime(0.15, audioCtx.currentTime); // 音量は控えめに
bgmNode.connect(bgmGain);
bgmGain.connect(audioCtx.destination);
bgmNode.start();
}
function stopBGM() {
if (bgmNode) {
bgmNode.stop();
bgmNode = null;
}
}
// --- ランキング関連 (localStorage) ---
const STORAGE_KEY = 'hcr_ranking';
function saveScore(name, score) {
let ranking = getRanking();
ranking.push({ name: name || '名無しの巫女', score: score });
ranking.sort((a, b) => b.score - a.score); // スコア降順
ranking = ranking.slice(0, 5); // トップ5のみ保持
localStorage.setItem(STORAGE_KEY, JSON.stringify(ranking));
}
function getRanking() {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
}
function displayRanking() {
const ranking = getRanking();
const list = document.getElementById('ranking-list');
list.innerHTML = '';
if (ranking.length === 0) {
list.innerHTML = '<li>まだ記録はありません</li>';
return;
}
ranking.forEach((entry, index) => {
const li = document.createElement('li');
li.innerHTML = `
<span class="rank-num">${index + 1}.</span>
<span class="rank-name">${escapeHTML(entry.name)}</span>
<span class="rank-score">${entry.score}</span>
`;
list.appendChild(li);
});
}
function escapeHTML(str) {
return str.replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
});
}
// --- ゲームロジック ---
const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");
const scoreSpan = document.getElementById("score");
const stageSpan = document.getElementById("stage");
const townHealthFill = document.getElementById("townHealthFill");
const gameoverDiv = document.getElementById("gameover");
const startScreen = document.getElementById("start-screen");
const finalScoreSpan = document.getElementById("final-score");
const retryButton = document.getElementById("retryButton");
const cityHeight = canvas.height / 2;
let score = 0, currentStage = 1, townHealth = 100, gameState = "title"; // title, playing, gameover
const goddessHeight = 15;
let goddessWidth = 90, goddessX = (canvas.width - goddessWidth) / 2;
let rightPressed = false, leftPressed = false, goddessSpeed = 7;
const bulletRadius = 8;
let bullets = [];
const demonWidth = 35, demonHeight = 35;
let demonBaseSpeed = 0.2, demons = [], demonSpawnInterval = 2000;
let lastDemonSpawnTime = 0, demonSpeed = demonBaseSpeed;
let boss = null, enemiesDefeatedForNextBoss = 10;
let items = [], itemRadius = 15, itemSpawnInterval = 8000, lastItemSpawnTime = 0;
let stageNoticeTimer = 0;
function init() {
score = 0; currentStage = 1; townHealth = 100;
enemiesDefeatedForNextBoss = 10; demonBaseSpeed = 0.2; gameState = "playing";
goddessWidth = 90; goddessSpeed = 7; goddessX = (canvas.width - goddessWidth) / 2;
bullets = [];
for(let i=0; i<3; i++) {
bullets.push({ x: canvas.width/4*(i+1), y: 30+Math.random()*30, dx: (Math.random()-0.5)*4, dy: 2+Math.random()*2, power: 1 });
}
demons = []; items = []; boss = null;
lastDemonSpawnTime = Date.now(); lastItemSpawnTime = Date.now();
updateUI();
startScreen.style.display = "none";
gameoverDiv.style.display = "none";
initAudio(); // 最初のクリックでオーディオ初期化
if(audioCtx) {
stopBGM(); startBGM(); // BGMを最初から
}
requestAnimationFrame(draw);
}
document.addEventListener("keydown", (e) => {
if(e.key == "Right" || e.key == "ArrowRight") rightPressed = true;
else if(e.key == "Left" || e.key == "ArrowLeft") leftPressed = true;
}, false);
document.addEventListener("keyup", (e) => {
if(e.key == "Right" || e.key == "ArrowRight") rightPressed = false;
else if(e.key == "Left" || e.key == "ArrowLeft") leftPressed = false;
}, false);
// 最初は画面クリックでスタート
canvas.addEventListener("click", () => {
if (gameState === "title") init();
});
retryButton.addEventListener("click", init);
function updateUI() {
scoreSpan.innerText = score; stageSpan.innerText = currentStage;
townHealthFill.style.width = townHealth + "%";
if(townHealth > 60) townHealthFill.style.backgroundColor = "#0f0";
else if(townHealth > 30) townHealthFill.style.backgroundColor = "#fa0";
else townHealthFill.style.backgroundColor = "#f00";
}
function spawnItem(x, y) {
const types = ["multi", "angels", "bomb", "gSpeed", "dSlow", "heal"];
const type = types[Math.floor(Math.random() * types.length)];
items.push({ x: x, y: y, type: type, dy: 1.5 });
}
function getItemEmoji(type) {
switch(type) {
case "multi": return "☄️"; case "angels": return "👼"; case "bomb": return "💣";
case "gSpeed": return "⚡"; case "dSlow": return "🐌"; case "heal": return "💖";
}
}
function applyItemEffect(type) {
playSE('item');
switch(type) {
case "multi":
if (bullets.length > 0) bullets.push({ x: bullets[0].x, y: bullets[0].y - 10, dx: -bullets[0].dx, dy: -Math.abs(bullets[0].dy), power: 1 });
else bullets.push({ x: goddessX + goddessWidth/2, y: canvas.height - 30, dx: 2, dy: -3, power: 1 });
break;
case "angels":
goddessWidth += 30; setTimeout(() => { goddessWidth = Math.max(90, goddessWidth - 30); }, 10000); break;
case "bomb":
demons.forEach(d => { d.health -= 15; if(d.health <= 0) score++; });
demons = demons.filter(d => d.health > 0);
if(boss) boss.health -= 20;
ctx.fillStyle = "rgba(255, 255, 255, 0.7)"; ctx.fillRect(0,0, canvas.width, canvas.height);
updateUI(); break;
case "gSpeed":
goddessSpeed += 2; break;
case "dSlow":
demonSpeed = demonBaseSpeed * 0.3; setTimeout(() => { demonSpeed = demonBaseSpeed; }, 5000); break;
case "heal":
townHealth = Math.min(100, townHealth + 30); updateUI(); break;
}
}
function spawnBoss() {
boss = { x: canvas.width/2 - 40, y: 40, width: 80, height: 80, health: 40+(currentStage*20), maxHealth: 40+(currentStage*20) };
}
function nextStage() {
playSE('clear');
currentStage++; score += 50; townHealth = Math.min(100, townHealth + 20);
enemiesDefeatedForNextBoss = score + 15 + (currentStage * 5);
demonBaseSpeed += 0.05; demonSpeed = demonBaseSpeed; boss = null;
stageNoticeTimer = 120; updateUI();
}
function gameOver() {
gameState = "gameover";
stopBGM();
playSE('over');
finalScoreSpan.innerText = score;
gameoverDiv.style.display = "block";
// 少し遅れて名前入力を求める
setTimeout(() => {
const name = prompt(`街は陥落しました...\nあなたのスコア: ${score}\nランキングに刻む名を(10文字以内)`, '名無しの巫女');
saveScore(name ? name.substring(0, 10) : '名無しの巫女', score);
displayRanking();
}, 500);
}
function collisionDetection() {
for (let i = 0; i < bullets.length; i++) {
let bullet = bullets[i];
for (let j = demons.length - 1; j >= 0; j--) {
let d = demons[j];
if (bullet.x > d.x - bulletRadius && bullet.x < d.x + demonWidth + bulletRadius &&
bullet.y > d.y - bulletRadius && bullet.y < d.y + demonHeight + bulletRadius) {
if (bullet.x < d.x || bullet.x > d.x + demonWidth) bullet.dx = -bullet.dx; else bullet.dy = -bullet.dy;
d.health -= bullet.power;
playSE('enemy');
if (d.health <= 0) {
playSE('kill');
demons.splice(j, 1); score++; updateUI();
if (score >= enemiesDefeatedForNextBoss && !boss) spawnBoss();
}
}
}
if (boss) {
if (bullet.x > boss.x - bulletRadius && bullet.x < boss.x + boss.width + bulletRadius &&
bullet.y > boss.y - bulletRadius && bullet.y < boss.y + boss.height + bulletRadius) {
if (bullet.x < boss.x || bullet.x > boss.x + boss.width) bullet.dx = -bullet.dx; else bullet.dy = -bullet.dy;
boss.health -= bullet.power;
playSE('enemy');
if (boss.health <= 0) nextStage();
}
}
if (bullet.y + bullet.dy > canvas.height - goddessHeight - bulletRadius) {
if (bullet.x > goddessX - bulletRadius && bullet.x < goddessX + goddessWidth + bulletRadius) {
playSE('paddle');
bullet.dy = -Math.abs(bullet.dy); bullet.y = canvas.height - goddessHeight - bulletRadius;
if(bullet.power < 5) bullet.power++;
let hitPoint = bullet.x - (goddessX + goddessWidth/2); bullet.dx = hitPoint * 0.1;
} else if (bullet.y + bullet.dy > canvas.height + bulletRadius) {
bullets.splice(i, 1); i--;
if (bullets.length === 0) {
townHealth -= 10; updateUI();
if(townHealth <= 0) { townHealth = 0; updateUI(); gameOver(); }
else {
playSE('town');
bullets.push({ x: canvas.width/2, y: canvas.height-40, dx: (Math.random()-0.5)*4, dy: -3, power: 1 });
}
}
}
}
}
for (let j = demons.length - 1; j >= 0; j--) {
let d = demons[j];
if (d.y + demonHeight > cityHeight) {
playSE('town');
townHealth -= (d.type === "midboss" ? 15 : 8); demons.splice(j, 1); updateUI();
if (townHealth <= 0) { townHealth = 0; updateUI(); gameOver(); }
}
}
for (let k = items.length - 1; k >= 0; k--) {
let item = items[k];
if (item.y + itemRadius > canvas.height - goddessHeight && item.y - itemRadius < canvas.height &&
item.x + itemRadius > goddessX && item.x - itemRadius < goddessX + goddessWidth) {
applyItemEffect(item.type); items.splice(k, 1);
} else if (item.y > canvas.height + itemRadius) { items.splice(k, 1); }
}
}
function draw() {
if (gameState !== "playing") return;
const currentTime = Date.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 街エリアの背景(邪悪な霧を表現)
ctx.fillStyle = "rgba(30, 10, 50, 0.4)"; ctx.fillRect(0, cityHeight, canvas.width, cityHeight);
// 境界線(聖なる結界)
ctx.strokeStyle = "rgba(100, 255, 255, 0.5)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, cityHeight); ctx.lineTo(canvas.width, cityHeight); ctx.stroke();
// 女神(パドル)
ctx.fillStyle = "#fff"; ctx.shadowBlur = 15; ctx.shadowColor = "#fff";
ctx.fillRect(goddessX, canvas.height - goddessHeight, goddessWidth, goddessHeight);
ctx.shadowBlur = 0;
ctx.font = "16px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "#000";
ctx.fillText("🧚‍♀️", goddessX + goddessWidth/2, canvas.height - goddessHeight/2);
// 光弾
for (let b of bullets) {
ctx.beginPath(); ctx.arc(b.x, b.y, bulletRadius + (b.power*0.5), 0, Math.PI * 2);
ctx.fillStyle = `hsl(60, 100%, ${50 + b.power * 8}%)`;
ctx.shadowBlur = 5 + b.power * 2; ctx.shadowColor = "#fff";
ctx.fill(); ctx.shadowBlur = 0;
}
// 敵
ctx.textAlign = "center"; ctx.textBaseline = "middle";
for (let d of demons) {
ctx.font = "30px sans-serif";
ctx.fillText(d.type === "midboss" ? "👹" : "🦇", d.x + demonWidth/2, d.y + demonHeight/2);
ctx.fillStyle = "red"; ctx.fillRect(d.x, d.y - 5, demonWidth, 3);
ctx.fillStyle = "green"; ctx.fillRect(d.x, d.y - 5, demonWidth * (d.health/(d.type === "midboss"?15:3)), 3);
}
// ボス
if (boss) {
ctx.font = "60px sans-serif";
ctx.fillText("🐉", boss.x + boss.width/2, boss.y + boss.height/2);
ctx.fillStyle = "red"; ctx.fillRect(boss.x, boss.y - 10, boss.width, 5);
ctx.fillStyle = "green"; ctx.fillRect(boss.x, boss.y - 10, boss.width * (boss.health/boss.maxHealth), 5);
}
// アイテム
for (let item of items) {
ctx.beginPath(); ctx.arc(item.x, item.y, itemRadius + 5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255, 255, 100, 0.2)"; ctx.fill(); // ふんわりとした光
ctx.font = "24px sans-serif"; ctx.fillText(getItemEmoji(item.type), item.x, item.y);
}
if (stageNoticeTimer > 0) {
ctx.fillStyle = `rgba(255, 255, 0, ${stageNoticeTimer / 120})`;
ctx.font = "bold 40px 'Georgia'"; ctx.fillText(`STAGE ${currentStage} CLEAR!`, canvas.width/2, canvas.height/2);
stageNoticeTimer--;
}
collisionDetection();
for (let b of bullets) {
if (b.x + b.dx > canvas.width - bulletRadius || b.x + b.dx < bulletRadius) b.dx = -b.dx;
if (b.y + b.dy < bulletRadius) b.dy = -b.dy;
b.x += b.dx; b.y += b.dy;
}
if (currentTime - lastDemonSpawnTime > demonSpawnInterval && !boss) {
let isMid = Math.random() < 0.1;
demons.push({ x: Math.random() * (canvas.width - demonWidth), y: -demonHeight, type: isMid ? "midboss" : "normal", health: isMid ? 15 : 3 });
lastDemonSpawnTime = currentTime;
}
demons.forEach(d => d.y += demonSpeed);
if (currentTime - lastItemSpawnTime > itemSpawnInterval) {
spawnItem(Math.random() * (canvas.width - itemRadius*2) + itemRadius, -itemRadius);
lastItemSpawnTime = currentTime;
}
items.forEach(i => i.y += i.dy);
if (rightPressed && goddessX < canvas.width - goddessWidth) goddessX += goddessSpeed;
else if (leftPressed && goddessX > 0) goddessX -= goddessSpeed;
requestAnimationFrame(draw);
}
// 初期表示
displayRanking();
</script>
</body>
</html>


■ 今日の学び

今回のゲームは、バグが多くて最初の頃にいったんあきらめてしまった作品を、 「今なら作り直せるかも!」と思って再挑戦したものです。

あの時よりもパワーアップしたAI(有料版)の協力もあって、 無事に形にできてとても満足しています(=v=

しばらく進捗を書いていませんでしたが、ゲーム作りは毎日コツコツ続けていて、 ついに 90個を突破 しました!(R8年4月15日現在)

最初の目標だった100個まで、あともう少し(>v<)”

100個作ったら、これまでのゲームたちをどんどんパワーアップさせたいと思っていましたが、 AIと一緒に毎日新しいゲームを作るのも、今はすごく楽しいです(^^*)

やりたいこと全部やろうとすると時間が足りなくて、ちょっともどかしいけれど、 ブログづくりもゲームづくりも、バランスよく楽しみながら続けていきますね。

■ 次回は、お絵描きゲーム第4弾

次回は、「重なり」と「触れられる絵」 をテーマにした、お絵描きゲーム第4弾です。 ガラス越しに絵を重ねていくような、不思議でちょっと魔法みたいな体験になっています。

次回もお楽しみに。(^v^)ノシ

次の実験はこちら(像の重なる絵画体験):
「硝子のアトリエ」



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

~このブログについて~

AIと一緒に遊び、
100の実験でゲームを育てるブログ

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー


AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む