実験#99:乾燥と粉砕と取り込み(アメーバー乾燥粉砕)

■99個目 「アメーバー乾燥粉砕」

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

今回は、薄暗い洞窟を舞台にした、
ちょっぴり不思議で爽快なアクションゲームを作ってみました。

ぬるぬるとうごめきながら迫ってくる緑色の「アメーバ」たちを、
乾燥の力でカチカチにして粉砕していくゲームです。

今回の主人公は、以前の作品にも登場した「根っこの妖精」の仲間で、
全身が赤いキュートな 乾燥の妖精

頭の葉っぱのお皿と、
まんまるの瞳がチャームポイントです。(=v=*)

妖精の得意技は「乾燥ビーム」。

これを浴びたアメーバは一瞬で干からびて、
カチコチの塊に変わります。

そこへ思いきって体当たりすると…… パリーン! と粉々になり、
風にフワ〜ッと溶けるように消えていくんです。

この“風化して消える”パラパラ感が、
とにかくクセになる爽快ポイント。(^_^*)

乾燥させたアメーバからは、ポロッと光る「核」が落ちてきたり、

ゲームが進むと巨大なボスアメーバが道をふさぐように待ち構えていたりと、
可愛い見た目の裏にちょっぴりスリリングな展開も用意しています。

粉砕の気持ちよさと、
洞窟探索のドキドキを楽しんでもらえたら嬉しいです。(=v=*)


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

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

みー:(^v^) 今日もゲームを作ります。HTMLで動くゲームで、
じわじわ近寄ってくるアメーバっぽい生物。
当たるとダメージだけど、
乾燥ビームで乾かして触れると粉になって風化消滅するゲーム。

AI: 「乾燥させて粉砕する」というコンセプト、
すごく爽快ですね。

今回は、マウスで狙って乾燥ビームを撃ち、
乾いたアメーバに体当たりして粉砕する
アクションを重視したゲームを作成しました。

● 粉砕エフェクトのこだわり

みー:(*^_^)粉砕エフェクトを
「大 → 中 → 小 → フェードアウト」の段階で変化させたい。
自然で気持ちの良い消え方にしたいです。

AI: 了解です!パーティクルのサイズと透明度を寿命と連動させて、
段階的に小さくなるように調整しました。

さらに、少し重力と空気抵抗を加えて、
粉がふわっと漂うような動きを再現しています。

みー:(=v=) 最小粉レベルまで小さくしたいね。
洞窟内の空気に乗って漂う感じが欲しい。

AI: 乾燥した瞬間に大きな破片がパサッと砕け、
そこから超微細な粉塵へと変化します。

粉は洞窟の空気に乗ってふわりと漂い、
最後は空気に溶けるように消えていく…

そんな風化の瞬間を表現しました。

● アメーバのリアリティアップ

みー:(^_^) 次はアメーバの動きと見た目をリアルにしたい。
変形しながらうごめく不定形スライムで、
中に核がうっすら見える感じ。

緑色の集合体みたいな存在で。

AI: いいですね!単なる円ではなく、
複数の制御点をベジェ曲線で結んで不規則に変形するようにしました。

内部には複数の細胞小器官と、うっすら見える核も描画しています。

● 核の回収システムの追加

みー:(^_^) 乾燥ビームを当てるとアメーバの核が放出される。

ゆっくり漂うので、キャッチすると少し回復する機能をつけたい。

AI: 乾燥した瞬間にポロッと核が落ちる仕様を追加しました。

新しい「Nucleus」クラスを作り、
ふわふわ漂うように動くようにしています。

拾うと少し回復するので、前線に飛び込む理由が生まれますね。


■ 実際の動作

まずは、AIが最初に作ってくれた初期バージョンのゲーム画面です。
見た目はとてもシンプルですが、
希望していた「乾燥 → 粉砕」の仕組みがすでにしっかり動いていました。

(画像:AIが最初に出してくれた初期画面)



こちらが、最終的に仕上がったゲーム画面です。
赤い乾燥の妖精ちゃんが主人公で、
うねうねと寄ってくるアメーバを乾燥ビームでカラカラにし、
体当たりで粉々にしていきます。

(画像:完成版のスタート画面)


乾燥させたアメーバはビームを通さなくなり、
ちょっとした障害物になります。

そのまま放置すると邪魔になるので、
どんどん体当たりして壊していきましょう。

(画像:乾燥したアメーバが道をふさいでいる様子)

乾燥させるとアメーバは「核」を放出します。
これを受け取るとスコアポイントと回復の両方が手に入ります。
ただし、ボスも核を吸収して回復するので、奪われないよう注意が必要です。

(画像:オリーブ色のアメーバのボス)


HPが0以下になるとゲームオーバーです。
F5キーでいつでもリトライできます。

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


■ コードのポイント

今回の記事では、
AI と相談しながら作った「アメーバー乾燥粉砕」 の HTML コードを公開します。

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

ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。

▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く

Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)

② 下のコードをコピーして貼り付けます。
③ 「kannsoufunnsai.html」など、好きな名前で保存します。

(※拡張子は .html にしてください)

④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。

■ 操作方法

操作はとってもシンプルです!(^^*)
初めての方でもすぐに遊べます。

【移動】

キーボードの W・A・S・Dキー または 矢印キー で、
妖精ちゃんを上下左右に動かします。

【乾燥ビーム】

マウスで狙いをつけて、 左クリック でビームを発射!

【ゲームのルール】

  • 緑のアメーバにビームを当てると、
    カチカチの茶色に乾燥して動きが止まります。
  • 乾燥したアメーバに妖精ちゃんで 体当たり すると、
    粉々に風化消滅してスコアが入ります!(^_^)
  • 生きたままの緑アメーバに触れると ダメージ を受けるので、
    うまく距離をとって戦ってくださいね。

【お助けアイテム:光る核】

アメーバを乾燥させると、中からポロッと光る 「核」 が落ちてきます。
拾うと HPが少し回復 し、さらに スコアボーナス も入ります。(=v=*)

勇気を出して前に出て、たくさん集めてみてくださいね。

【ボスの襲来……!】

スコアが 10000点を超えるごとに、巨大な ボスアメーバ が登場します。

  • ビームを 30回 当てないと倒せない強敵
  • 落ちている核を 吸収して回復 してしまう
  • オリーブ色の巨体で画面を圧迫してくる

逃げ回りながら隙を見て乾燥させ、 粉砕していきましょう。(^^*)

パサッ……パリーン! この粉砕の気持ちよさを、
ぜひブラウザで体験してみてください!


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>アメーバ乾燥&微細風化消滅ゲーム</title>
<style>
body { margin: 0; overflow: hidden; background-color: #1a1a1a; color: white; font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; }
canvas { background-color: #262626; box-shadow: 0 0 30px rgba(0, 0, 0, 0.9); cursor: crosshair; }
#ui { position: absolute; top: 20px; left: 20px; pointer-events: none; text-shadow: 2px 2px 5px rgba(0,0,0,0.7); z-index: 10; }
.recover-text { color: #55ff55; font-weight: bold; }
.boss-warning { color: #ff5555; animation: blink 1s infinite; }
#startMsg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; pointer-events: none; color: #aaa; animation: blink 2s infinite; }
@keyframes blink { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }
</style>
</head>
<body>
<div id="ui">
<h2>HP: <span id="hpDisplay">100</span></h2>
<h2>SCORE: <span id="scoreDisplay">0</span></h2>
<h2 id="bossWarning" style="display:none;" class="boss-warning">⚠️ BOSS APPROACHING ⚠️</h2>
</div>
<div id="startMsg">CLICK TO START</div>
<canvas id="gameCanvas" width="900" height="700"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const hpDisplay = document.getElementById('hpDisplay');
const scoreDisplay = document.getElementById('scoreDisplay');
const bossWarning = document.getElementById('bossWarning');
const startMsg = document.getElementById('startMsg');
// --- サウンドシステム (Web Audio API) ---
let actx;
let audioInit = false;
function initAudio() {
if (audioInit) return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
actx = new AudioContext();
audioInit = true;
startMsg.style.display = 'none';
playBGM();
}
function playBGM() {
let noteIndex = 0;
const bass = [110, 110, 130.81, 146.83]; // A2, A2, C3, D3
const arp = [220, 261.63, 329.63, 440]; // A3, C4, E4, A4
setInterval(() => {
if (!actx || gameOver) return;
let t = actx.currentTime;
// ベースライン
if (noteIndex % 4 === 0) {
let osc = actx.createOscillator();
let gain = actx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(bass[(noteIndex/4)%4], t);
gain.gain.setValueAtTime(0.2, t);
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.6);
osc.connect(gain);
gain.connect(actx.destination);
osc.start(t);
osc.stop(t + 0.6);
}
// アルペジオ(洞窟のしずくのような音)
let osc2 = actx.createOscillator();
let gain2 = actx.createGain();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(arp[noteIndex%4], t);
gain2.gain.setValueAtTime(0.05, t);
gain2.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
osc2.connect(gain2);
gain2.connect(actx.destination);
osc2.start(t);
osc2.stop(t + 0.2);
noteIndex++;
}, 250);
}
// 効果音ジェネレーター
function playSE(type) {
if (!actx) return;
let t = actx.currentTime;
let osc = actx.createOscillator();
let gain = actx.createGain();
osc.connect(gain);
gain.connect(actx.destination);
switch(type) {
case 'shoot': // ビーム
osc.type = 'square';
osc.frequency.setValueAtTime(800, t);
osc.frequency.exponentialRampToValueAtTime(100, t + 0.1);
gain.gain.setValueAtTime(0.1, t);
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.1);
osc.start(t);
osc.stop(t + 0.1);
break;
case 'dry': // 乾燥(ノイズ)
playNoise(0.1, 0.2, 500, 'lowpass');
break;
case 'crush': // 粉砕(ガラスのような高音ノイズ)
playNoise(0.2, 0.4, 2000, 'highpass');
break;
case 'boss_crush': // ボス粉砕(重い爆発音)
playNoise(0.8, 0.6, 300, 'lowpass');
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, t);
osc.frequency.exponentialRampToValueAtTime(20, t + 0.8);
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.8);
osc.start(t);
osc.stop(t + 0.8);
break;
case 'heal': // 回復(心地よいチャイム)
osc.type = 'sine';
osc.frequency.setValueAtTime(523.25, t); // C5
osc.frequency.setValueAtTime(659.25, t + 0.1); // E5
gain.gain.setValueAtTime(0.15, t);
gain.gain.linearRampToValueAtTime(0, t + 0.3);
osc.start(t);
osc.stop(t + 0.3);
break;
case 'damage': // ダメージ
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(100, t);
osc.frequency.linearRampToValueAtTime(50, t + 0.2);
gain.gain.setValueAtTime(0.2, t);
gain.gain.linearRampToValueAtTime(0, t + 0.2);
osc.start(t);
osc.stop(t + 0.2);
break;
case 'alarm': // ボス警告
osc.type = 'square';
osc.frequency.setValueAtTime(400, t);
osc.frequency.setValueAtTime(600, t + 0.2);
gain.gain.setValueAtTime(0.1, t);
gain.gain.linearRampToValueAtTime(0, t + 0.4);
osc.start(t);
osc.stop(t + 0.4);
break;
}
}
function playNoise(duration, vol, freq, filterType) {
if (!actx) return;
const bufferSize = actx.sampleRate * duration;
const buffer = actx.createBuffer(1, bufferSize, actx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = actx.createBufferSource();
noise.buffer = buffer;
const filter = actx.createBiquadFilter();
filter.type = filterType;
filter.frequency.value = freq;
const gain = actx.createGain();
gain.gain.setValueAtTime(vol, actx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, actx.currentTime + duration);
noise.connect(filter);
filter.connect(gain);
gain.connect(actx.destination);
noise.start();
}
// --- ゲームの状態 ---
let hp = 100;
let score = 0;
let gameOver = false;
let frameCount = 0;
let gameTime = 0;
let nextBossScore = 10000;
let bossSpawnCount = 1;
// --- 入力処理 ---
const keys = {};
let mouseX = canvas.width / 2;
let mouseY = canvas.height / 2;
window.addEventListener('keydown', e => keys[e.key] = true);
window.addEventListener('keyup', e => keys[e.key] = false);
window.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
});
window.addEventListener('mousedown', () => {
if (!audioInit) initAudio();
if (!gameOver) shootBeam();
});
// --- 風化パーティクル ---
function getWindForce(x, y, time) {
const scale = 0.012;
const speed = 0.07;
const nx = x * scale;
const ny = y * scale;
const nt = time * speed;
const forceX = Math.sin(nx + nt) * 0.25 + Math.cos(ny * 1.5 - nt * 1.1) * 0.12;
const forceY = Math.cos(ny + nt * 0.9) * 0.25 + Math.sin(nx * 1.5 - nt * 1.2) * 0.12 - 0.06;
return { fx: forceX, fy: forceY };
}
class AdvancedParticle {
constructor(x, y, color) {
this.x = x;
this.y = y;
const angle = Math.random() * Math.PI * 2;
const initialSpeed = Math.random() * 6 + 4;
this.vx = Math.cos(angle) * initialSpeed;
this.vy = Math.sin(angle) * initialSpeed;
this.friction = 0.97;
this.baseSize = Math.random() * 1.8 + 0.8;
this.size = this.baseSize;
const colorOffset = (Math.random() - 0.5) * 15;
const r = parseInt(color.substring(1,3), 16) + colorOffset;
const g = parseInt(color.substring(3,5), 16) + colorOffset;
const b = parseInt(color.substring(5,7), 16) + colorOffset;
this.color = `#${Math.max(0, Math.min(255, parseInt(r))).toString(16).padStart(2, '0')}${Math.max(0, Math.min(255, parseInt(g))).toString(16).padStart(2, '0')}${Math.max(0, Math.min(255, parseInt(b))).toString(16).padStart(2, '0')}`;
this.life = 1.0;
this.decay = Math.random() * 0.018 + 0.012;
this.timeOffset = Math.random() * 2000;
}
update(time) {
const wind = getWindForce(this.x, this.y, time + this.timeOffset);
this.vx += wind.fx;
this.vy += wind.fy;
this.vx *= this.friction;
this.vy *= this.friction;
this.x += this.vx;
this.y += this.vy;
this.life -= this.decay;
if (this.life > 0.7) {
this.size = this.baseSize * 1.1;
} else if (this.life > 0.4) {
this.size = this.baseSize;
} else {
this.size = Math.max(0.2, this.baseSize * (this.life / 0.4));
}
}
draw() {
if (this.life <= 0) return;
ctx.beginPath();
const alpha = Math.max(0, this.life);
const r = parseInt(this.color.substring(1,3), 16);
const g = parseInt(this.color.substring(3,5), 16);
const b = parseInt(this.color.substring(5,7), 16);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.closePath();
}
}
// --- 主人公(乾燥の妖精) ---
class Player {
constructor() {
this.x = canvas.width / 2;
this.y = canvas.height / 2;
this.size = 18;
this.speed = 4.5;
this.bodyColor = '#d95b43';
this.leafColor = '#a63c2c';
this.eyeColor = '#2b1a18';
}
update() {
if (keys['w'] || keys['ArrowUp']) this.y -= this.speed;
if (keys['s'] || keys['ArrowDown']) this.y += this.speed;
if (keys['a'] || keys['ArrowLeft']) this.x -= this.speed;
if (keys['d'] || keys['ArrowRight']) this.x += this.speed;
this.x = Math.max(this.size, Math.min(canvas.width - this.size, this.x));
this.y = Math.max(this.size + 10, Math.min(canvas.height - this.size, this.y));
}
draw() {
ctx.fillStyle = this.leafColor;
ctx.beginPath();
ctx.ellipse(this.x, this.y - this.size + 2, 14, 5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#662211';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(this.x + 8, this.y - this.size + 2);
ctx.quadraticCurveTo(this.x + 12, this.y - this.size - 4, this.x + 16, this.y - this.size - 2);
ctx.stroke();
ctx.fillStyle = this.bodyColor;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.eyeColor;
ctx.beginPath();
ctx.arc(this.x - 6, this.y - 2, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(this.x + 6, this.y - 2, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 120, 120, 0.6)';
ctx.beginPath();
ctx.arc(this.x - 9, this.y + 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(this.x + 9, this.y + 4, 3, 0, Math.PI * 2);
ctx.fill();
}
}
class Beam {
constructor(x, y, targetX, targetY) {
this.x = x;
this.y = y;
this.speed = 14;
this.radius = 5;
const angle = Math.atan2(targetY - y, targetX - x);
this.vx = Math.cos(angle) * this.speed;
this.vy = Math.sin(angle) * this.speed;
this.active = true;
}
update() {
this.x += this.vx;
this.y += this.vy;
if (this.x < 0 || this.x > canvas.width || this.y < 0 || this.y > canvas.height) {
this.active = false;
}
}
draw() {
ctx.fillStyle = '#ffcc00';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
}
class Nucleus {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = 8;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 0.8 + 0.3;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.active = true;
this.wobble = 0;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.wobble += 0.08;
if (this.x < -30 || this.x > canvas.width + 30 || this.y < -30 || this.y > canvas.height + 30) {
this.active = false;
}
}
draw() {
const currentRadius = this.radius + Math.sin(this.wobble) * 2;
ctx.beginPath();
ctx.arc(this.x, this.y, currentRadius * 1.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(80, 255, 120, 0.3)';
ctx.fill();
ctx.beginPath();
ctx.arc(this.x, this.y, currentRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(10, 50, 25, 0.9)';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(80, 255, 120, 0.8)';
ctx.stroke();
}
}
// --- 通常アメーバ(敵) ---
class Amoeba {
constructor() {
const edge = Math.floor(Math.random() * 4);
if (edge === 0) { this.x = Math.random() * canvas.width; this.y = -40; }
else if (edge === 1) { this.x = canvas.width + 40; this.y = Math.random() * canvas.height; }
else if (edge === 2) { this.x = Math.random() * canvas.width; this.y = canvas.height + 40; }
else { this.x = -40; this.y = Math.random() * canvas.height; }
this.radius = 25 + Math.random() * 10;
this.baseSpeed = 0.3 + Math.random() * 0.6;
this.state = 'normal';
this.active = true;
this.wobble = Math.random() * Math.PI * 2;
this.driedColor = '#9a7b5c';
this.bossBumpCount = 0;
this.bossBumpTime = 0;
this.organelles = [];
const numOrganelles = Math.floor(Math.random() * 3) + 3;
for (let i = 0; i < numOrganelles; i++) {
this.organelles.push({
angleOffset: Math.random() * Math.PI * 2,
speed: Math.random() * 1.5 + 0.5,
dist: Math.random() * 0.5 + 0.1,
size: Math.random() * 0.3 + 0.15,
color: ['rgba(140, 255, 100, 0.6)', 'rgba(60, 200, 80, 0.5)', 'rgba(100, 240, 50, 0.4)'][Math.floor(Math.random() * 3)]
});
}
}
update(px, py) {
if (this.state === 'normal') {
const angle = Math.atan2(py - this.y, px - this.x);
this.x += Math.cos(angle) * this.baseSpeed;
this.y += Math.sin(angle) * this.baseSpeed;
this.wobble += 0.04;
}
}
draw() {
let nodes = 12;
let coords = [];
for (let i = 0; i < nodes; i++) {
let angle = (i / nodes) * Math.PI * 2;
let deformation = Math.sin(this.wobble * 2 + i * 1.5) * (this.radius * 0.15) +
Math.cos(this.wobble * 1.2 + i * 3) * (this.radius * 0.1);
let r = this.radius + deformation;
if (this.state === 'dried') {
r -= (i % 2 === 0 ? 0 : this.radius * 0.3);
}
coords.push({x: this.x + Math.cos(angle) * r, y: this.y + Math.sin(angle) * r});
}
ctx.beginPath();
ctx.moveTo((coords[0].x + coords[nodes - 1].x) / 2, (coords[0].y + coords[nodes - 1].y) / 2);
for (let i = 0; i < nodes; i++) {
let next = coords[(i + 1) % nodes];
let curr = coords[i];
ctx.quadraticCurveTo(curr.x, curr.y, (curr.x + next.x) / 2, (curr.y + next.y) / 2);
}
ctx.closePath();
if (this.state === 'normal') {
ctx.fillStyle = 'rgba(30, 140, 70, 0.8)';
ctx.fill();
for (let org of this.organelles) {
ctx.beginPath();
let ox = this.x + Math.cos(this.wobble * org.speed + org.angleOffset) * (this.radius * org.dist);
let oy = this.y + Math.sin(this.wobble * org.speed + org.angleOffset) * (this.radius * org.dist);
let oR = (this.radius * org.size) + Math.sin(this.wobble * 3) * 2;
ctx.arc(ox, oy, Math.max(1, oR), 0, Math.PI * 2);
ctx.fillStyle = org.color;
ctx.fill();
}
ctx.beginPath();
let nucX = this.x + Math.cos(this.wobble * 0.8) * (this.radius * 0.15);
let nucY = this.y + Math.sin(this.wobble * 1.1) * (this.radius * 0.15);
let nucR = this.radius * 0.35 + Math.sin(this.wobble * 2.5) * 1.5;
ctx.arc(nucX, nucY, Math.max(1, nucR), 0, Math.PI * 2);
ctx.fillStyle = 'rgba(10, 50, 25, 0.85)';
ctx.fill();
} else {
ctx.fillStyle = this.driedColor;
ctx.fill();
ctx.strokeStyle = 'rgba(90, 60, 40, 0.7)';
ctx.lineWidth = 1;
for (let i = 0; i < nodes; i += 2) {
ctx.beginPath();
ctx.moveTo(this.x + Math.cos(this.wobble)*2, this.y + Math.sin(this.wobble)*2);
ctx.lineTo(coords[i].x, coords[i].y);
ctx.stroke();
}
}
}
}
// --- ボスアメーバ ---
class BossAmoeba extends Amoeba {
constructor() {
super();
this.radius = 100 + Math.random() * 20;
this.baseSpeed = 0.18;
this.maxHits = 30;
this.hits = 30;
this.normalBaseColor = 'rgba(128, 128, 0, 0.85)';
this.driedColor = '#4a4a15';
this.organelles = [];
const numOrganelles = 12;
for (let i = 0; i < numOrganelles; i++) {
this.organelles.push({
angleOffset: Math.random() * Math.PI * 2,
speed: Math.random() * 1.0 + 0.2,
dist: Math.random() * 0.6 + 0.1,
size: Math.random() * 0.2 + 0.05,
color: ['rgba(160, 160, 50, 0.6)', 'rgba(100, 100, 20, 0.5)', 'rgba(200, 200, 80, 0.4)'][Math.floor(Math.random() * 3)]
});
}
}
draw() {
let nodes = 16;
let coords = [];
for (let i = 0; i < nodes; i++) {
let angle = (i / nodes) * Math.PI * 2;
let deformation = Math.sin(this.wobble * 2 + i * 1.5) * (this.radius * 0.1) +
Math.cos(this.wobble * 1.2 + i * 3) * (this.radius * 0.08);
let r = this.radius + deformation;
if (this.state === 'dried') r -= (i % 2 === 0 ? 0 : this.radius * 0.2);
coords.push({x: this.x + Math.cos(angle) * r, y: this.y + Math.sin(angle) * r});
}
ctx.beginPath();
ctx.moveTo((coords[0].x + coords[nodes - 1].x) / 2, (coords[0].y + coords[nodes - 1].y) / 2);
for (let i = 0; i < nodes; i++) {
let next = coords[(i + 1) % nodes];
let curr = coords[i];
ctx.quadraticCurveTo(curr.x, curr.y, (curr.x + next.x) / 2, (curr.y + next.y) / 2);
}
ctx.closePath();
if (this.state === 'normal') {
let damageRatio = 1 - (this.hits / this.maxHits);
let r = 128 + damageRatio * 50;
let g = 128 - damageRatio * 50;
ctx.fillStyle = `rgba(${r}, ${g}, 0, 0.85)`;
ctx.fill();
for (let org of this.organelles) {
ctx.beginPath();
let ox = this.x + Math.cos(this.wobble * org.speed + org.angleOffset) * (this.radius * org.dist);
let oy = this.y + Math.sin(this.wobble * org.speed + org.angleOffset) * (this.radius * org.dist);
let oR = (this.radius * org.size) + Math.sin(this.wobble * 3) * 5;
ctx.arc(ox, oy, Math.max(1, oR), 0, Math.PI * 2);
ctx.fillStyle = org.color;
ctx.fill();
}
ctx.beginPath();
let nucX = this.x + Math.cos(this.wobble * 0.8) * (this.radius * 0.15);
let nucY = this.y + Math.sin(this.wobble * 1.1) * (this.radius * 0.15);
let nucR = this.radius * 0.25 + Math.sin(this.wobble * 2.5) * 3;
ctx.arc(nucX, nucY, Math.max(1, nucR), 0, Math.PI * 2);
ctx.fillStyle = 'rgba(50, 50, 0, 0.9)';
ctx.fill();
const barWidth = 100;
const barHeight = 10;
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(this.x - barWidth/2, this.y - this.radius - 20, barWidth, barHeight);
ctx.fillStyle = '#ffcc00';
ctx.fillRect(this.x - barWidth/2, this.y - this.radius - 20, barWidth * (this.hits / this.maxHits), barHeight);
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;
ctx.strokeRect(this.x - barWidth/2, this.y - this.radius - 20, barWidth, barHeight);
} else {
ctx.fillStyle = this.driedColor;
ctx.fill();
ctx.strokeStyle = 'rgba(50, 50, 20, 0.8)';
ctx.lineWidth = 2;
for (let i = 0; i < nodes; i += 2) {
ctx.beginPath();
ctx.moveTo(this.x + Math.cos(this.wobble)*5, this.y + Math.sin(this.wobble)*5);
ctx.lineTo(coords[i].x, coords[i].y);
ctx.stroke();
}
}
}
}
// --- ゲームオブジェクト ---
const player = new Player();
let beams = [];
let amoebas = [];
let bosses = [];
let particles = [];
let nuclei = [];
let floatingTexts = [];
function shootBeam() {
playSE('shoot');
beams.push(new Beam(player.x, player.y, mouseX, mouseY));
}
function spawnParticles(x, y, color, amount = 120) {
for (let i = 0; i < amount; i++) {
particles.push(new AdvancedParticle(x, y, color));
}
}
function gameLoop() {
if (gameOver) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'white';
ctx.font = '52px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2);
ctx.font = '28px sans-serif';
ctx.fillText(`SCORE: ${score}`, canvas.width / 2, canvas.height / 2 + 55);
ctx.fillText('F5キーでリトライ', canvas.width / 2, canvas.height / 2 + 100);
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
frameCount++;
gameTime += 1;
// ★スコア10000ごとにボス
if (score >= nextBossScore) {
playSE('alarm');
for (let i = 0; i < bossSpawnCount; i++) {
bosses.push(new BossAmoeba());
}
if (bossSpawnCount === 1) {
bossWarning.innerText = `⚠️ BOSS APPROACHING ⚠️`;
} else {
bossWarning.innerText = `⚠️ ${bossSpawnCount} BOSSES APPROACHING! ⚠️`;
}
bossSpawnCount++;
nextBossScore += 10000;
bossWarning.style.display = 'block';
setTimeout(() => bossWarning.style.display = 'none', 3000);
}
if (frameCount % 70 === 0) {
amoebas.push(new Amoeba());
}
player.update();
player.draw();
for (let i = beams.length - 1; i >= 0; i--) {
const b = beams[i];
b.update();
b.draw();
if (!b.active) beams.splice(i, 1);
}
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.update(gameTime);
p.draw();
if (p.life <= 0) particles.splice(i, 1);
}
// --- ボスの更新と衝突判定 ---
for (let i = bosses.length - 1; i >= 0; i--) {
const boss = bosses[i];
boss.update(player.x, player.y);
boss.draw();
for (let j = beams.length - 1; j >= 0; j--) {
const b = beams[j];
const dist = Math.hypot(boss.x - b.x, boss.y - b.y);
if (dist < boss.radius + b.radius) {
if (boss.state === 'normal') {
boss.hits -= 1;
floatingTexts.push({x: b.x, y: b.y, life: 0.8, text: "-1", color: "255, 255, 255"});
if (boss.hits <= 0) {
boss.state = 'dried';
playSE('dry');
for(let k=0; k<5; k++) nuclei.push(new Nucleus(boss.x + (Math.random()-0.5)*100, boss.y + (Math.random()-0.5)*100));
}
}
b.active = false;
}
}
if (boss.state === 'normal') {
for (let a of amoebas) {
if (a.state === 'dried') {
const dist = Math.hypot(boss.x - a.x, boss.y - a.y);
if (dist < boss.radius + a.radius) {
if (gameTime - a.bossBumpTime > 30) {
a.bossBumpCount += 1;
if (a.bossBumpCount === 1) {
const angle = Math.atan2(boss.y - a.y, boss.x - a.x);
boss.x += Math.cos(angle) * 30;
boss.y += Math.sin(angle) * 30;
a.bossBumpTime = gameTime;
floatingTexts.push({x: a.x, y: a.y, life: 1.0, text: "BUMP!", color: "200, 200, 200"});
} else {
spawnParticles(a.x, a.y, a.driedColor, 60);
playSE('crush');
a.active = false;
floatingTexts.push({x: a.x, y: a.y, life: 1.0, text: "CRUSH!!", color: "255, 100, 100"});
}
}
}
}
}
}
if (boss.state === 'normal') {
for (let n of nuclei) {
const dist = Math.hypot(boss.x - n.x, boss.y - n.y);
if (dist < boss.radius + n.radius) {
boss.hits = Math.min(boss.maxHits, boss.hits + 5);
n.active = false;
playSE('heal');
floatingTexts.push({x: boss.x, y: boss.y - boss.radius - 30, life: 1.0, text: "BOSS HEAL +5", color: "200, 255, 50"});
}
}
}
const distToPlayer = Math.hypot(boss.x - player.x, boss.y - player.y);
if (distToPlayer < boss.radius * 0.8 + player.size) {
if (boss.state === 'normal') {
hp -= 50;
hpDisplay.innerText = hp;
playSE('damage');
const angle = Math.atan2(player.y - boss.y, player.x - boss.x);
player.x += Math.cos(angle) * 80;
player.y += Math.sin(angle) * 80;
if (hp <= 0) gameOver = true;
} else if (boss.state === 'dried') {
spawnParticles(boss.x, boss.y, boss.driedColor, 400);
score += 5000;
scoreDisplay.innerText = score;
playSE('boss_crush');
boss.active = false;
floatingTexts.push({x: boss.x, y: boss.y, life: 1.5, text: "BOSS DESTROYED! +5000", color: "255, 204, 0"});
}
}
if (!boss.active) bosses.splice(i, 1);
}
// --- 核の更新 ---
for (let i = nuclei.length - 1; i >= 0; i--) {
const n = nuclei[i];
n.update();
n.draw();
const distToPlayer = Math.hypot(n.x - player.x, n.y - player.y);
if (distToPlayer < n.radius + player.size) {
hp = Math.min(100, hp + 5);
hpDisplay.innerText = hp;
hpDisplay.classList.add("recover-text");
setTimeout(() => hpDisplay.classList.remove("recover-text"), 200);
score += 50;
scoreDisplay.innerText = score;
playSE('heal');
floatingTexts.push({x: n.x, y: n.y - 15, life: 1.0, text: "HP +5", color: "80, 255, 120"});
floatingTexts.push({x: n.x, y: n.y + 5, life: 1.0, text: "SCORE +50", color: "255, 204, 0"});
n.active = false;
}
if (!n.active) nuclei.splice(i, 1);
}
// --- 通常アメーバの更新 ---
for (let i = amoebas.length - 1; i >= 0; i--) {
const a = amoebas[i];
a.update(player.x, player.y);
a.draw();
for (let j = beams.length - 1; j >= 0; j--) {
const b = beams[j];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
if (dist < a.radius + b.radius) {
if (a.state === 'normal') {
a.state = 'dried';
playSE('dry');
nuclei.push(new Nucleus(a.x, a.y));
}
b.active = false;
}
}
const distToPlayer = Math.hypot(a.x - player.x, a.y - player.y);
if (distToPlayer < a.radius * 0.8 + player.size) {
if (a.state === 'normal') {
hp -= 20;
hpDisplay.innerText = hp;
playSE('damage');
a.active = false;
const angle = Math.atan2(player.y - a.y, player.x - a.x);
player.x += Math.cos(angle) * 30;
player.y += Math.sin(angle) * 30;
if (hp <= 0) gameOver = true;
} else if (a.state === 'dried') {
spawnParticles(a.x, a.y, a.driedColor);
playSE('crush');
score += 100;
scoreDisplay.innerText = score;
a.active = false;
}
}
if (!a.active) amoebas.splice(i, 1);
}
// 浮遊テキスト描画
for (let i = floatingTexts.length - 1; i >= 0; i--) {
const ft = floatingTexts[i];
ft.y -= 1;
ft.life -= 0.03;
ctx.fillStyle = `rgba(${ft.color}, ${ft.life})`;
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(ft.text, ft.x, ft.y);
if (ft.life <= 0) floatingTexts.splice(i, 1);
}
requestAnimationFrame(gameLoop);
}
gameLoop();
</script>
</body>
</html>


■ 今日の学び

今回のゲームは、これまでの実験室で生まれた
さまざまなアイデアがつながって形になった、

ちょっと特別な“集大成”のような作品になりました。

主人公の 乾燥の妖精ちゃん は、
第82回『ルーツ・スプライト』に登場した
“小さな根の精霊” の仲間という裏設定があります。

乾燥ビームや、敵から落ちた「核」を拾って回復する仕組みは、
そのゲームで使ったシステムを受け継いだものです。

敵として登場する緑のアメーバたちは、
第77回の『緑の集合体』のイメージがベースになっています。

そして今回もっともこだわった「粉砕」のパリン!という感触は、
ブログには載せていない秘密の

『粉砕シミュレーター』(第51回の乾燥シミュレーターの派生)で試していた表現を取り入れました。

こうして、過去のゲームの設定や実験してきたシステムを
“根拠”としてつなぎ合わせていくことで、

ただのアクションゲームではなく、
世界観に深みが生まれていくのを感じました。( ˘ω˘ )✧

今回のゲームは、操作が意外とシビアです。

キーボードで移動しながら、マウスで照準を合わせる必要があるので、
慣れるまでは少し忙しいかもしれません。

特に厄介なのが、乾燥させたアメーバが
「壁」になってビームを通さなくなる こと!

向こう側の敵を狙いたくても届かず、
せっかく落ちた回復の核もフワフワ漂ってしまうので
キャッチするのに一苦労……。

ボスに辿り着く頃には、手汗がじんわり出ているかもしれません。( Ꙭ )!!

高得点を狙うのは難しいですが、
そのぶん何度も挑戦したくなる
“やり込めるゲーム” に仕上がったと思います。ᕙ( ˙-˙ )ᕗ

そして……お気付きの方もいるかもしれませんが、
今回の記事で当ブログのゲーム制作はついに #99 を迎えました。

ブログタイトルにもある「AIと作る100のゲーム実験室」。
その 100個目のゴール が、もうすぐそこまで来ています。

次回の記念すべき第100回。
一体どんな実験になるのか、ぜひ楽しみにしていてくださいね!⸜( ´ ꒳ ` )⸝✩︎⡱

■ 次回は、100個目のゲームに、久しぶりのあのキャラクターが登場します。

ブログを始めたばかりのころに作った、 #02「丸太郎と突撃猪」

ゲーム内容はまったく別物ですが、
なんと今回の100個目のゲームでは、
この「丸太郎と突撃猪」が再び登場します!

初期のころのキャラクターが、 長い実験室の旅を経て、
記念すべき100回目に帰ってくる……

そんな“シリーズならではの楽しみ”を味わっていただけると思います。

以前のゲームとの違いや、 丸太郎たちがどんな形で活躍するのか、

ぜひ楽しみにしていてくださいね。(*^v^)ノシ

▶ 次の実験はこちら  
(わんぱく丸太郎の自由すぎる冒険)  
「保護サークル・サバイバル:丸太郎と突撃猪」



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

~このブログについて~

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

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


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

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

続きを読む