実験#109:オクラのねばねばで一網打尽(ねばねばバネバネねっとりネット)

■109個目 「ねばねばバネバネねっとりネット」

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

今回は、ちょっぴり変わった爽快アクションゲームを作ってみました。

今日の主役は……なんと夏野菜の “オクラ” です!

平和な食卓に、イタズラ好きのウィルス軍団が突如として襲来。

そんなピンチに立ち上がったのが、
正義の野菜ヒーロー「オクラくん」。(=v=*)

オクラといえば、あの独特の「ネバネバ」。

今回はそのネバネバパワーを最大限に活かして、
ばねのように飛び出す特製の粘着ネットで
ウィルスたちを一網打尽にしちゃいます。

こだわったのは、「ねばねば・ねっとり」した手触りと、
たくさん巻き込んだときのコンボの爽快感。(´▽`)

一気にウィルスを捕まえた瞬間の「バシッ!」という気持ちよさは、
クセになること間違いなしです。

画面の横では、「赤オクラ」ちゃんや「丸オクラ」くん、
そして可憐な「オクラの花」ちゃんたち応援団が、
ぴょんぴょん跳ねながらオクラくんの活躍を見守ってくれます。

その姿がまた、とても可愛いんです。

さらに、「ビヨォン!」「ネチャァ…」といった、
ちょっと不思議でレトロな手作り効果音も、このゲームの大切なスパイス。

ねばねばネットが伸びる感触や、ウィルスを捕まえる瞬間の重みを、
耳からも楽しめるようになっています。

果たしてオクラくんは、制限時間内にすべてのウィルスを退治して、
平和な食卓を守り切ることができるのでしょうか。(`・ω・´)

ねばねばパワー全開の、
ちょっと変わったヒーローアクションをお楽しみください。


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

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

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

今回は、オクラのキャラクター「オクラくん」が活躍する、
ねばねばアクションゲーム。

ねばねばのクオリティを高く、
ばねのように飛び出す粘着ネットで
ウィルスたちを一網打尽にしながら進むゲームにしたいです。

AI: オクラの断面の星型を活かした「オクラくん」が、
画面いっぱいに広がるネバネバネットで
ウィルスを捕獲する爽快アクションゲームを作成しました。

PCのメモアプリにコピペするだけで動く、
1ファイル構成の実験室スタイルです。

今回は「ばねのように飛び出し、
ネバネバで一網打尽にする」という気持ちよさを重視し、
ネットが弾けるアニメーションや、
捕まえたウィルスに糸が絡みつくエフェクトを取り入れています。

みー:(^_^) いいね。
一回に捕まえるウイルス数が多いほどポイントが高くなります。

一定時間に全部捕まえられないとゲームオーバー。

画面横には左に世界観と操作説明、
右にスコアランキングをお願いします。

AI: いらっしゃい、みーさん!今日も実験室の新作づくりだね。

コンボボーナス、タイムリミット、
左右に世界観とランキングを配置したアーケード風レイアウト……

ゲーム性がぐっと深まり、熱中できる仕上がりになってきたよ。

複数のウィルスを同時に捕まえたときに
「バシッ!」と高得点が飛び出す演出も追加して、
一網打尽の爽快感をさらに強調しておいたよ。

みー:(^o^) ハイスコアの下のスペースが寂しいので、
オクラの花や丸オクラ、赤オクラなど仲間たちの応援演出を入れてみましょう。

AI: 右パネルのスペースに、可愛い応援団を配置しました!

赤い断面が特徴の「赤オクラ」、やさしい形の「丸オクラ」、
そしてクリーム色の花びらが美しい「オクラの花」の3キャラクターが、
ぴょんぴょん跳ねながらオクラくんを応援してくれます。

さらに、プレイ状況(コンボ成功・残り時間が少ない時)に合わせて
セリフが変わるように連動させました。

みー:(^w^)/ このゲームに効果音とBGMをお願い。
テーマは「ねばねばばねばねねっとりねっと」。

AI: いらっしゃい!
テーマが「ねばねばばねばねねっとりねっと」なんて、
最高にクセになる響きだね。

今回は外部音声を使わず、
ブラウザ標準の Web Audio API で音を合成する仕組みを追加したよ。

  • ばねの音(発射):「ビヨォン!」とピッチが跳ね上がる音
  • ねっとり音(展開):「ネチャァ…」と低くうねる音
  • コンボ音:まとめて捕まえるほど音程が高くなる気持ちいい音
  • BGM:ベースがうねり、メロディが滑らかにつながる不思議な曲調(ポルタメント)

ブラウザの仕様上、「画面を最初にクリック(タップ)した瞬間」に
音が鳴り始めるようになっています。

みー:(つ_=) 連射で何とかなる感じなので、
少し難易度を上げたいです。ゲーム画面を広げることできますか?

AI: 確かに、連射だけでクリアできてしまうと
アクションゲームとしての歯ごたえが薄くなりますね。

ウィルスが逃げ回るスペースを広げるため、
ゲーム画面(キャンバス)を「縦横700px」へ拡大しました。

画面が広くなったことで、ウィルス同士の距離が離れやすくなり、
適当な連射では一網打尽にしにくくなっています。

しっかり引きつけてから撃つ戦略が必要になりますよ。

レイアウト幅やオクラくんの立ち位置も調整済みです。

みー:( ̄v ̄) このゲームのブラッシュアップをお願い。

AI: 前回の「連射で何とかなる」という課題を解決しつつ、
ゲームの手触りと爽快感を一段階引き上げるブラッシュアップを施しました。

主な改良点は以下の3つです。

1. スコアUIのポップエフェクト

高得点コンボ時にスコア表示が「ボワッ」と大きくなる
CSSアニメーションを追加し、得点の気持ちよさを強調。

2. ネバネバ液(スタミナ)制限

無駄撃ちを防ぐため、オクラくんにスタミナゲージを追加。
緑色のリングが1発ごとに消費し、時間で回復。

「ここぞ」というタイミングで撃つ戦略性が生まれました。

3. 弾けるパーティクル(飛沫)演出

ウィルス捕獲時やネット消滅時に、
ネバネバの飛沫が飛び散るパーティクルを追加。

視覚的な「バシッ!」という手応えが大幅にアップしています。


■ 実際の動作

まずは、AIが最初に作ってくれた試作版のゲーム画面です。
シンプルながらイメージに近い仕上がりだったので、
ここから少しずつ改良を重ねていきました。

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


こちらが、ブラッシュアップを重ねて完成したゲーム画面です。
画面をクリックすると音が鳴り始まり、ねばねばアクションがスタートします。

(画像:仕上がりのゲーム画面)


飛び回るウイルスたちを、タイミングよくねばねばネットで捕まえていきます。
一度にたくさん巻き込むほどコンボボーナスが入り、
「バシッ!」という爽快感が広がります。

(画像:ゲーム画面)




時間内にウィルスをすべて捕まえられないとゲームオーバー。
ハイスコアは右側のランキングに記録されていき、
挑戦するたびに食卓の平和度が上がっていきます。

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



■ コードのポイント

今回の記事では、
AI と相談しながら作った「ねばねばバネバネねっとりネット」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 遊び方・操作方法

ゲームの操作はとっても簡単です。(^_^)

PCならマウスのクリッックで遊べますよ。
まずは、画面の中を自由に動き回るウィルスたちを観察してみてくださいね。

● ネットを発射!

ウィルスがいる場所をクリックすると、
オクラくんがネバネバネットを撃ち出します。

ネットは目標に届くと、ばねのように「ビヨォン!」と一気に広がり、
ウィルスを包み込むように捕まえます。(=v=*)

少しだけ引きつけてから狙うと、より大きなネットが決まりやすいですよ。

● スタミナに注意

オクラくんの周りにある緑色の輪っかは「スタミナ」のゲージです。

連続で撃ちすぎると輪っかが赤くなり、
一時的にネットが撃てなくなってしまいます。

無駄撃ちを避けて、
ここぞというタイミングで撃つのがポイントです。(´・ω・`)

● めざせ大連鎖(コンボ)!

このゲームの最大の魅力は「まとめどり」。

ネットが広がった瞬間に、たくさんのウィルスを一度に巻き込むと、
コンボボーナスで得点がドカンと跳ね上がります!(´▽`)

ウィルスたちが集まってくるのをじっくり待ってから撃つと、
高得点が狙えますよ。

● 時間内にクリアしよう

画面上のタイム(TIME)が0になる前に、
すべてのウィルスを捕まえてくださいね。

残り時間が少なくなると、
応援団のみんなもぴょんぴょん跳ねながら焦り出します。(>_<)

その姿がまた可愛くて、つい頑張りたくなります。

今回はブラウザの機能を使って、
プログラムから直接
「ねばねば・ねっとり」した音を作り出しています。(^^*)

画面を一度クリックすると音が鳴り始まるので、
ぜひ音を出して、不思議なサウンドと一緒に楽しんでみてくださいね。


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>オクラくんのネバネバ・ネットアクション</title>
<style>
body {
margin: 0; padding: 20px; background-color: #0f172a; color: #f8fafc;
display: flex; justify-content: center; font-family: 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh; box-sizing: border-box;
}
.layout-container { display: flex; flex-direction: row; gap: 20px; max-width: 1350px; width: 100%; align-items: flex-start; }
.panel { background-color: #1e293b; border-radius: 12px; padding: 20px; width: 260px; flex-shrink: 0; box-shadow: 0 8px 16px rgba(0,0,0,0.5); }
.panel h2 { color: #4ade80; font-size: 1.2rem; border-bottom: 2px solid #334155; padding-bottom: 10px; margin-top: 0; }
.panel p { font-size: 0.95rem; line-height: 1.6; color: #cbd5e1; }
.game-area { flex-grow: 1; display: flex; flex-direction: column; align-items: center; position: relative; }
.status-bar { width: 100%; max-width: 700px; display: flex; justify-content: space-between; margin-bottom: 10px; font-size: 1.2rem; font-weight: bold; }
.status-item span {
color: #4ade80;
display: inline-block;
transition: transform 0.1s ease-out; /* スコアが跳ねるアニメーション用 */
}
.status-item.time span { color: #f87171; }
canvas { background-color: #0b1120; box-shadow: 0 0 30px rgba(74, 222, 128, 0.15); border-radius: 10px; cursor: crosshair; border: 2px solid #334155; }
.ranking-list { list-style: none; padding: 0; margin: 0; }
.ranking-list li { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed #334155; font-family: monospace; font-size: 1.1rem; }
.ranking-list li span:first-child { color: #fbbf24; }
.cheer-section { margin-top: 40px; text-align: center; }
.cheer-container { display: flex; justify-content: center; gap: 15px; margin: 15px 0; }
.cheer-char { width: 60px; height: 60px; }
.bounce1 { animation: bounce 1.2s infinite ease-in-out; }
.bounce2 { animation: bounce 1.5s infinite ease-in-out 0.3s; }
.sway { animation: sway 2.5s infinite ease-in-out; transform-origin: bottom center; }
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-12px); } }
@keyframes sway { 0%, 100% { transform: rotate(-10deg); } 50% { transform: rotate(10deg); } }
.cheer-balloon {
background-color: #334155; padding: 12px; border-radius: 10px; font-size: 0.95rem; color: #f8fafc;
position: relative; display: inline-block; font-weight: bold; transition: color 0.2s;
}
.cheer-balloon::before {
content: ''; position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
border-width: 0 10px 10px 10px; border-style: solid; border-color: transparent transparent #334155 transparent;
}
#soundNotice {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8); padding: 20px; border-radius: 10px; text-align: center;
border: 2px solid #4ade80; pointer-events: none; z-index: 10;
}
</style>
</head>
<body>
<div class="layout-container">
<div class="panel">
<h2>🔰 世界観</h2>
<p>平和な食卓を脅かすウィルス軍団が襲来!<br>野菜界のヒーロー「オクラくん」は、持ち前のネバネバパワーで立ち上がった。</p>
<h2>🎮 操作説明</h2>
<p>・画面内をクリックしてネットを発射!<br>・<strong>オクラくんの周りの緑の輪はスタミナです。連射しすぎると撃てなくなります。</strong><br>・多く巻き込むほどコンボボーナス!</p>
</div>
<div class="game-area">
<div class="status-bar">
<div class="status-item">STAGE: <span id="stageDisp">1</span></div>
<div class="status-item">SCORE: <span id="scoreDisp">0</span></div>
<div class="status-item time">TIME: <span id="timeDisp">30.0</span></div>
</div>
<div id="soundNotice">画面をクリックして<br>音とゲームをスタート!</div>
<canvas id="gameCanvas" width="700" height="700"></canvas>
</div>
<div class="panel">
<h2>🏆 ランキング</h2>
<ul class="ranking-list" id="rankingList"></ul>
<div class="cheer-section">
<h2>📣 応援団</h2>
<div class="cheer-container">
<div class="cheer-char bounce1"><svg viewBox="0 0 100 100"><path d="M50 5 L70 30 L95 45 L75 75 L60 95 L40 95 L25 75 L5 45 L30 30 Z" fill="#ef4444" stroke="#7f1d1d" stroke-width="4" stroke-linejoin="round"/><circle cx="35" cy="50" r="5" fill="#0f172a"/><circle cx="65" cy="50" r="5" fill="#0f172a"/><path d="M 45 65 Q 50 75 55 65" stroke="#0f172a" stroke-width="4" stroke-linecap="round" fill="none"/></svg></div>
<div class="cheer-char bounce2"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="45" fill="#a3e635" stroke="#166534" stroke-width="4"/><circle cx="35" cy="45" r="5" fill="#0f172a"/><circle cx="65" cy="45" r="5" fill="#0f172a"/><path d="M 40 60 Q 50 70 60 60" stroke="#0f172a" stroke-width="4" stroke-linecap="round" fill="none"/></svg></div>
<div class="cheer-char sway"><svg viewBox="0 0 100 100"><g stroke="#ca8a04" stroke-width="2"><circle cx="50" cy="20" r="18" fill="#fef08a"/><circle cx="78" cy="40" r="18" fill="#fef08a"/><circle cx="67" cy="72" r="18" fill="#fef08a"/><circle cx="33" cy="72" r="18" fill="#fef08a"/><circle cx="22" cy="40" r="18" fill="#fef08a"/></g><circle cx="50" cy="50" r="18" fill="#9f1239"/><circle cx="41" cy="48" r="3" fill="#fff"/><circle cx="59" cy="48" r="3" fill="#fff"/><path d="M 45 57 Q 50 63 55 57" stroke="#fff" stroke-width="2" stroke-linecap="round" fill="none"/></svg></div>
</div>
<div class="cheer-balloon" id="cheerText">「ねばねば〜!」</div>
</div>
</div>
</div>
<script>
const AudioContext = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
let isBgmPlaying = false; let bgmOsc = null, bgmGain = null; let noteIndex = 0;
const bgmSequence = [ {f: 130, d: 0.4}, {f: 164, d: 0.4}, {f: 130, d: 0.4}, {f: 196, d: 0.8}, {f: 146, d: 0.4}, {f: 174, d: 0.4}, {f: 146, d: 0.4}, {f: 220, d: 0.8} ];
function initAudio() {
if (!audioCtx) { audioCtx = new AudioContext(); document.getElementById('soundNotice').style.display = 'none'; }
if (audioCtx.state === 'suspended') audioCtx.resume();
}
function playBGM() {
if (!audioCtx || isBgmPlaying || gameState !== 'playing') return;
isBgmPlaying = true; bgmOsc = audioCtx.createOscillator(); bgmGain = audioCtx.createGain();
bgmOsc.type = 'triangle'; bgmOsc.connect(bgmGain); bgmGain.connect(audioCtx.destination); bgmOsc.start();
function scheduleNextNote() {
if (!isBgmPlaying) return;
const note = bgmSequence[noteIndex]; const now = audioCtx.currentTime;
bgmOsc.frequency.setValueAtTime(bgmOsc.frequency.value || note.f, now); bgmOsc.frequency.exponentialRampToValueAtTime(note.f, now + 0.2);
bgmGain.gain.setValueAtTime(0.05, now); bgmGain.gain.linearRampToValueAtTime(0.15, now + (note.d / 2)); bgmGain.gain.linearRampToValueAtTime(0.05, now + note.d);
noteIndex = (noteIndex + 1) % bgmSequence.length; setTimeout(scheduleNextNote, note.d * 1000);
}
scheduleNextNote();
}
function stopBGM() {
isBgmPlaying = false; if (bgmOsc) { bgmOsc.stop(); bgmOsc.disconnect(); } if (bgmGain) bgmGain.disconnect();
}
function playSE(type, param) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination); const now = audioCtx.currentTime;
if (type === 'shoot') {
osc.type = 'sine'; osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(800, now + 0.15);
gain.gain.setValueAtTime(0.3, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15); osc.start(now); osc.stop(now + 0.15);
} else if (type === 'expand') {
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(100, now); osc.frequency.linearRampToValueAtTime(40, now + 0.2);
gain.gain.setValueAtTime(0.1, now); gain.gain.linearRampToValueAtTime(0.2, now + 0.1); gain.gain.linearRampToValueAtTime(0.01, now + 0.3); osc.start(now); osc.stop(now + 0.3);
} else if (type === 'catch') {
osc.type = 'square'; const baseFreq = 400 + (param * 100);
osc.frequency.setValueAtTime(baseFreq, now); osc.frequency.setValueAtTime(baseFreq * 1.5, now + 0.05);
gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2); osc.start(now); osc.stop(now + 0.2);
} else if (type === 'gameover') {
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(200, now); osc.frequency.exponentialRampToValueAtTime(20, now + 1);
gain.gain.setValueAtTime(0.3, now); gain.gain.linearRampToValueAtTime(0.01, now + 1); osc.start(now); osc.stop(now + 1);
} else if (type === 'error') {
// スタミナ切れの音
osc.type = 'triangle'; osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(100, now + 0.1);
gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.1); osc.start(now); osc.stop(now + 0.1);
}
}
const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d');
const stageDisp = document.getElementById('stageDisp'); const scoreDisp = document.getElementById('scoreDisp');
const timeDisp = document.getElementById('timeDisp'); const rankingList = document.getElementById('rankingList');
const cheerText = document.getElementById('cheerText');
let stage = 1; let score = 0; let timeLeft = 30.0;
let gameState = 'title'; let lastTime = performance.now(); let cheerTimer;
let viruses = []; let nets = []; let floatingTexts = []; let particles = []; // パーティクル配列を追加
let rankings = JSON.parse(localStorage.getItem('okra_rankings')) || [0, 0, 0, 0, 0];
// パーティクルクラス
class Particle {
constructor(x, y, color, speed) {
this.x = x; this.y = y;
this.vx = (Math.random() - 0.5) * speed;
this.vy = (Math.random() - 0.5) * speed;
this.life = 1.0;
this.color = color;
this.size = Math.random() * 4 + 2;
}
update() {
this.x += this.vx; this.y += this.vy;
this.life -= 0.03;
}
draw() {
ctx.save(); ctx.globalAlpha = Math.max(0, this.life);
ctx.fillStyle = this.color; ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
function createParticles(x, y, color, count, speed) {
for(let i=0; i<count; i++) particles.push(new Particle(x, y, color, speed));
}
function updateRankingDisplay() {
rankingList.innerHTML = '';
rankings.sort((a, b) => b - a).slice(0, 5).forEach((s, i) => {
const li = document.createElement('li'); li.innerHTML = `<span>${i + 1}位</span> <span>${s} pts</span>`; rankingList.appendChild(li);
});
}
updateRankingDisplay();
function setCheerMessage(text, duration = 2000) {
cheerText.innerText = text; clearTimeout(cheerTimer);
if (duration > 0) { cheerTimer = setTimeout(() => { if (gameState === 'playing' && timeLeft > 5) cheerText.innerText = '「ねばねば〜!」'; }, duration); }
}
function addScore(pts) {
score += pts; scoreDisp.innerText = score;
// ポップエフェクト
scoreDisp.style.transform = 'scale(1.5)';
setTimeout(() => { scoreDisp.style.transform = 'scale(1)'; }, 150);
}
const okra = {
x: 350, y: 650, radius: 35,
stamina: 100, // スタミナ(最大100)を追加
draw: function() {
// スタミナの回復
if (gameState === 'playing') this.stamina = Math.min(100, this.stamina + 0.6);
// スタミナゲージの描画
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius + 12, -Math.PI / 2, -Math.PI / 2 + (this.stamina / 100) * Math.PI * 2);
ctx.strokeStyle = this.stamina >= 33 ? '#bef264' : '#ef4444'; // 撃てる時は緑、足りない時は赤
ctx.lineWidth = 4; ctx.stroke();
// オクラ本体
ctx.fillStyle = '#4ade80'; ctx.beginPath();
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI) / 5 - Math.PI / 2; const r = this.radius * (i % 1 === 0 ? 1 : 0.8);
const px = this.x + Math.cos(angle) * r; const py = this.y + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#166534'; ctx.lineWidth = 4; ctx.stroke();
ctx.fillStyle = '#0f172a';
ctx.beginPath(); ctx.arc(this.x - 10, this.y - 5, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(this.x + 10, this.y - 5, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(this.x, this.y + 5, 5, 0, Math.PI); ctx.stroke();
}
};
class Virus {
constructor(x, y, speedMulti) {
this.x = x; this.y = y; this.radius = 15;
this.vx = (Math.random() - 0.5) * 6 * speedMulti; this.vy = (Math.random() - 0.5) * 6 * speedMulti;
this.caught = false; this.color = `hsl(${Math.random() * 40 + 280}, 80%, 60%)`;
this.rotation = 0; this.rotationSpeed = (Math.random() - 0.5) * 0.2;
}
update() {
if (!this.caught) {
this.x += this.vx; this.y += this.vy; this.rotation += this.rotationSpeed;
if (this.x < this.radius || this.x > canvas.width - this.radius) this.vx *= -1;
if (this.y < this.radius || this.y > canvas.height - 120) this.vy *= -1;
}
}
draw() {
ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.rotation);
ctx.fillStyle = this.color; ctx.beginPath();
for (let i = 0; i < 8; i++) {
const angle = (i * 2 * Math.PI) / 8; ctx.lineTo(Math.cos(angle) * this.radius, Math.sin(angle) * this.radius);
const innerAngle = angle + Math.PI / 8; ctx.lineTo(Math.cos(innerAngle) * (this.radius * 0.5), Math.sin(innerAngle) * (this.radius * 0.5));
}
ctx.closePath(); ctx.fill(); ctx.restore();
}
}
class Net {
constructor(startX, startY, targetX, targetY) {
this.x = startX; this.y = startY; this.tx = targetX; this.ty = targetY;
this.radius = 5; this.maxRadius = 140; this.state = 'shooting'; this.alpha = 1;
this.caughtViruses = []; this.scoreCalculated = false;
}
update() {
if (this.state === 'shooting') {
const dx = this.tx - this.x; const dy = this.ty - this.y; const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 15) {
this.state = 'expanding'; playSE('expand');
createParticles(this.x, this.y, '#a3e635', 10, 8); // 展開時の飛沫
}
else { this.x += dx * 0.25; this.y += dy * 0.25; }
} else if (this.state === 'expanding') {
this.radius += (this.maxRadius - this.radius) * 0.3;
viruses.forEach(v => {
if (!v.caught && Math.hypot(v.x - this.x, v.y - this.y) < this.radius) {
v.caught = true; this.caughtViruses.push(v);
createParticles(v.x, v.y, v.color, 8, 5); // 捕獲時の飛沫
}
});
if (this.maxRadius - this.radius < 2) {
this.state = 'fading';
if (this.caughtViruses.length > 0 && !this.scoreCalculated) {
const count = this.caughtViruses.length; const pts = count * count * 100;
addScore(pts);
floatingTexts.push({ x: this.x, y: this.y, text: `+${pts} (${count} COMBO!)`, alpha: 1 });
this.scoreCalculated = true; playSE('catch', count);
if (count >= 4) setCheerMessage(`「${count}匹まとめどり!」`);
else if (count >= 2) setCheerMessage(`「ばねばねコンボ!」`);
else setCheerMessage(`「ねっとりゲット!」`);
}
}
} else if (this.state === 'fading') {
this.alpha -= 0.05;
if (this.alpha <= 0) viruses = viruses.filter(v => !this.caughtViruses.includes(v));
}
}
draw() {
ctx.save(); ctx.globalAlpha = Math.max(0, this.alpha);
if (this.state === 'shooting' || this.state === 'expanding') {
ctx.strokeStyle = '#a3e635'; ctx.lineWidth = this.state === 'shooting' ? 6 : 2;
ctx.beginPath(); ctx.moveTo(okra.x, okra.y); ctx.lineTo(this.x, this.y); ctx.stroke();
}
if (this.state === 'expanding' || this.state === 'fading') {
ctx.fillStyle = 'rgba(163, 230, 53, 0.3)'; ctx.strokeStyle = '#84cc16'; ctx.lineWidth = 4;
ctx.beginPath(); const time = performance.now() * 0.01;
for(let i = 0; i < Math.PI * 2; i += 0.2) {
let r = this.radius + Math.sin(i * 6 + time) * 12; ctx.lineTo(this.x + Math.cos(i) * r, this.y + Math.sin(i) * r);
}
ctx.closePath(); ctx.fill(); ctx.stroke();
this.caughtViruses.forEach(v => {
ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(v.x, v.y);
ctx.strokeStyle = '#bef264'; ctx.lineWidth = 5; ctx.stroke();
});
}
ctx.restore();
}
}
function initStage(isReset = false) {
if(isReset) { stage = 1; score = 0; scoreDisp.innerText = 0; setCheerMessage('「ねばねば〜!」', 0); okra.stamina = 100; }
viruses = []; nets = []; floatingTexts = []; particles = [];
timeLeft = Math.max(10, 30 - (stage * 2));
let count = 5 + stage * 3; let speed = 1 + stage * 0.15;
for(let i=0; i<count; i++) viruses.push(new Virus(Math.random() * (canvas.width - 60) + 30, Math.random() * (canvas.height / 1.5) + 30, speed));
stageDisp.innerText = stage; gameState = 'playing'; lastTime = performance.now();
playBGM();
}
function gameOver() {
gameState = 'gameover'; stopBGM(); playSE('gameover');
setCheerMessage('「ねっとりダウン…」', 0); cheerText.style.color = "#f87171";
rankings.push(score); rankings.sort((a, b) => b - a); rankings = rankings.slice(0, 5);
localStorage.setItem('okra_rankings', JSON.stringify(rankings)); updateRankingDisplay();
}
canvas.addEventListener('mousedown', handleInput);
canvas.addEventListener('touchstart', handleInput, {passive: false});
function handleInput(e) {
e.preventDefault(); initAudio();
if (gameState === 'title' || gameState === 'gameover') { cheerText.style.color = "#f8fafc"; initStage(true); return; }
if (gameState === 'playing') {
// スタミナが33以上ないと撃てないように変更
if (okra.stamina >= 33) {
okra.stamina -= 33;
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY;
nets.push(new Net(okra.x, okra.y, clientX - rect.left, clientY - rect.top));
playSE('shoot');
} else {
playSE('error'); // スタミナ切れエラー音
}
}
}
function drawText(text, y, size, color) {
ctx.fillStyle = color; ctx.font = `bold ${size}px sans-serif`; ctx.textAlign = 'center'; ctx.fillText(text, canvas.width / 2, y);
}
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000; lastTime = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1;
for(let i=0; i<canvas.width; i+=40) { ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,canvas.height); ctx.stroke(); }
for(let i=0; i<canvas.height; i+=40) { ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(canvas.width,i); ctx.stroke(); }
if (gameState === 'playing') {
timeLeft -= dt; if (timeLeft <= 0) { timeLeft = 0; gameOver(); } timeDisp.innerText = timeLeft.toFixed(1);
if (timeLeft <= 5 && timeLeft > 0) { cheerText.innerText = "「ばねばね急いで!」"; cheerText.style.color = "#f87171"; }
else if (timeLeft > 5 && cheerText.innerText === "「ばねばね急いで!」") { cheerText.style.color = "#f8fafc"; setCheerMessage('「ねばねば〜!」', 0); }
viruses.forEach(v => { v.update(); v.draw(); });
nets.forEach((n, index) => { n.update(); n.draw(); if(n.alpha <= 0) nets.splice(index, 1); });
okra.draw();
particles.forEach((p, index) => { p.update(); p.draw(); if(p.life <= 0) particles.splice(index, 1); });
floatingTexts.forEach((ft, i) => {
ft.y -= 1; ft.alpha -= 0.02;
ctx.fillStyle = `rgba(250, 204, 21, ${Math.max(0, ft.alpha)})`;
ctx.font = 'bold 24px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(ft.text, ft.x, ft.y);
if(ft.alpha <= 0) floatingTexts.splice(i, 1);
});
if (viruses.length === 0 && nets.length === 0) { stage++; initStage(); setCheerMessage(`「ステージ${stage}もねっとり!」`); }
} else if (gameState === 'title') {
okra.draw(); drawText('CLICK TO START', canvas.height / 2, 40, '#4ade80');
} else if (gameState === 'gameover') {
viruses.forEach(v => v.draw()); okra.draw();
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
drawText('TIME UP!', canvas.height / 2 - 40, 60, '#f87171'); drawText(`FINAL SCORE: ${score}`, canvas.height / 2 + 20, 30, '#f8fafc');
drawText('CLICK TO RETRY', canvas.height / 2 + 80, 20, '#94a3b8');
}
requestAnimationFrame(gameLoop);
}
gameLoop(performance.now());
</script>
</body>
</html>


■ 今日の学び

今回のゲームの主役である「オクラ」。

実は私、数あるお野菜の中でもかなり好きなほうなんです。
あの独特のネバネバ感、とても美味しいですよね。

今回こだわった「一網打尽の爽快感」。

そのアイデアのもとになっているのは、
#48 の記事でご紹介した
『魔法の投網 – ちょびっと魔法と果樹園』の投網システムです。

あの時はファンタジーの世界で、
魔法の網をぽんっと広げて果実を捕まえる仕組みでしたが、
今回はオクラくんの特製ネバネバネット。

同じ「まとめて一気に捕まえる」という根幹の仕組みでも、
世界観を「野菜のネバネバ」や「ウィルス退治」に合わせて作り変えるだけで、

手触りもプレイの印象もまったく違う
新しいゲームに生まれ変わりました。( ¯꒳¯ )b✧

過去に作ったアイデアの種を、
別の世界観という新しい土壌にそっと植え替えてみる。

すると、まるで違う植物が芽を出すように、
全然違うゲームとして完成していく過程が、
今回作っていて最高に面白く感じた部分です。ᕙ( ˙-˙ )ᕗ

これからも、こんなふうに色々なアイデアや世界観を掛け合わせながら、
楽しく実験を続けていきたいと思います。

■ 次回は、脱走したハムスターを捕まえるゲームです。

冒険好きなハムスターは、気づくといつもケージから脱走しています。

ちょこちょこと走り回る姿は可愛いのですが、
捕まえるとなると意外とすばしっこいんです。

タイミングよく手を伸ばして、そっとケージに戻してあげましょう。

ただし──捕まえようとした先にいるのが、ハムスターとは限りません。

「捕まえられるかな?」というハラハラ感と、
「えっ…!?」とヒヤッとする瞬間が共生する、
ちょっと不思議なゲームになっています。

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

▶ 次の実験はこちら
(脱走したハムを保護、たまにゴキ)
  「ハムスターキャッチ」



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