実験#122:清流でスイカ割りサバイバル(WATERMELON SURVIVAL)

■122個目 「WATERMELON SURVIVAL

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

今回は、夏の清流を舞台にした、ちょっぴりスリリングでやさしい
アクションシューティングゲームを作ってみました。(^^*)

主役は……もちろん、大好きな “スイカ” です!

光がキラキラと揺らめく透明な水面。

そこにぷかりと浮かんでいるのは、
三角にカットされたみずみずしいスイカ(自機)。

透き通る水の中では、
いろんな大きさのお魚たちが気持ちよさそうに泳いでいます。

でも、このお魚たち……実はお腹がペコペコなんです!(><;)

放っておくと、おいしそうなスイカめがけて四方八方から
パクパクとかじりにやってきます。

「食べられちゃう前に、お魚たちのお腹を満たしてあげよう!」

そんな、ちょっとやさしいサバイバルが始まりました。(=v=*)

上からぷかぷか流れてくる丸いスイカに、
種ショットをパパパッと当ててパッカーン!

割れた果肉が水中に飛び散ると、
お魚たちは「ごはん見つけた!」と大喜びでそちらへ向かっていきます。

今回のこだわりポイントは、スイカの質感と、
お魚たちの命のめぐりです。(*^v^)

① 果肉の赤、皮の緑、白い層のグラデーション
② 撃ち出す種のツヤ感と、果汁がはじけるみずみずしいエフェクト
③ スイカのかけらを口をパクパクさせて食べるお魚たち
④ お腹いっぱいになると鮮やかな色になって、平和に泳ぎ去る姿

懐かしいピコピコとしたゲームボーイ風のBGMや効果音も鳴るようにして、
レトロでかわいい世界に仕上げてみました。

……でも、油断は禁物です! お魚が残り少なくなると、
エサに見向きもしない
“お怒りモードの突進魚” が飛んでくることも!?(゜Д゜;)

お魚たちをみんな満腹にして、清流に平和を取り戻せるでしょうか。

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

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

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

今回は、スイカを連想させる世界観がとても好きで、
色は濃い緑・黒・赤・白。 形は丸、半円、三角。
模様は縞々や種の点、そして切ったときの赤から白、皮へ続くグラデーション。 みずみずしい香りや質感、食感……。

それらをたっぷり使ったアクションシューティングゲームを作りたいです。

AI: スイカの色彩や形状、
そしてみずみずしい質感を活かしたアクションシューティング、
とても魅力的ですね!

濃緑と赤のコントラスト、丸や三角の形は画面映えします。

そこで、すぐにブラウザで遊べるプロトタイプを作ってみました。
今回のコードでは、以下の要素を盛り込んでいます。

  • 敵を撃破したときに果汁が弾ける赤いパーティクル
  • 三角形のスイカ(自機)の赤→白→緑のグラデーション
  • 黒いしずく型の「種ショット」
  • 縞模様の丸いスイカ(敵)

みー:(><;) 背景が黒で種が見えないよ。

AI: 確かに、黒い種が背景に溶け込んでしまっていましたね!

そこで、種に「白い縁取り」と「ツヤ(ハイライト)」を追加して
視認性を上げましょう。

みー:(つv=) 背景を水面ぽくしましょう。

揺らぎのある涼し気な感じでお願い。

AI: いいですね!スイカの“みずみずしさ”とも相性抜群です。

背景を深い緑に少し青を混ぜた水底のような色に変更し、
その上に水色の光の揺らぎ(コースティクス)をアニメーションさせました。

みー:(=v=) なんだか背景暗いよ。

もっと明るい清流の水面にして、いろんな大きさの魚を泳がせましょう。

敵をやっつけると砕けて、砕けたスイカには魚が寄ってきます。

AI: 素晴らしいアイディアです!

明るく透き通った清流の世界観に一新し、
魚たちが泳ぐ生態系を追加しました。

さらに、敵スイカを撃破すると砕け、
そのかけらに魚が集まるギミックも実装しました。

みー:( ̄v ̄) 魚がかけらに到達したとき、
口をパクパクさせるアニメーションを追加して、
満腹になったら色が少し鮮やかになるようにお願い。

AI: 命のめぐりを感じる素敵なアイディアですね! 以下の2点を追加しました。

  • 満腹による色変化
     透き通った青から、元気な青緑色へ
  • 口パクアニメーション
     かけらを食べるときに三角形が開閉して「パクパク」

みー:ヽ(^o^) 魚が自機を食べに来るのを、
スイカを割ることでそちらへ意識を向けるゲームにしましょう。

魚にかじられるとダメージ。
HP0でゲームオーバー。

AI: 美しい清流が一転してサバイバルに……!

以下のルールを追加しました。

  • HPが0になるとゲームオーバー
  • 空腹の魚は自機に近づき、触れるとダメージ
  • スイカを砕くと魚はそちらへ猛スピードで向かう
  • かけらを3回食べると満腹になり、鮮やかな色で無害化

みー:(^v^)
画面内のすべての魚を満腹にできたら「GAME CLEAR」にしましょう。

AI: 優しくて達成感のあるクリア条件ですね!

清流に平和を取り戻すゲームになりました。

みー:(^v^) ウェーブが進むごとに自機がパワーアップして、
種が2方向・3方向に飛ぶようにしてください。

AI: シューティングの醍醐味ですね!

種の角度も回転するように計算し、以下のように進化します。

  • WAVE1:1方向
  • WAVE2:V字の2方向
  • WAVE3:3WAY
  • WAVE4:4WAY
  • WAVE5以降:最大5WAY!

みー:(>v<)ノ 大きめスイカは耐久力高めに。

魚が残り少なくなったら突進魚を出しましょう。

AI: 終盤になると赤い「突進魚」が高速で画面外から飛んできます。

また、大きな丸スイカ(半径33以上)は耐久力2になり、
1発でヒビ、2発目で割れるようになりました。



■ 実際の動作

まずは、AI が最初に出してくれた試作版のゲーム画面をご紹介します。
上から落ちてくるスイカを、種ショットで撃ち落とすシンプルな仕組みです。
ただ、背景が黒だったため、黒い種がまったく見えず……ちょっと大変でした。(><;)

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


こちらが、調整を重ねて仕上がったゲーム画面です。
画面左にはスコアとゲームの説明、
画面右にはスコアランキングとリセットボタン、
そしてステージごとに変わる
“スイカをもっと好きになる雑学” を表示するようにしました。

右下に少しスペースができたので、
そこには大きなスイカをどーんと描いています。(^v^*)

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


白っぽい魚は「空腹」の状態で、自機のスイカを食べに近づいてきます。
そのままだと危ないので、丸いスイカを割って果肉をばらまき、
そちらへ誘導してあげましょう。

(画像:ゲーム画面)



スイカを食べて満足した魚は、体の色が少し濃くなり、
もう襲ってこなくなります。
残りの魚が少なくなってくると、
赤い「突進魚」が食べることとは関係なく
高速で突っ込んでくるので、よけながら最後の魚たちを満腹にさせます。

(画像:突進してくる赤い魚)


HPが 0 になるとゲームオーバーです。
上位の記録はランキングに表示されるので、何度でも挑戦できます。(=v=*)

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



■ コードのポイント

今回の記事では、
AI と相談しながら作ったWATERMELON SURVIVAL の HTML コードを公開します。

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

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

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

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

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

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

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

■ 遊び方・操作方法

【基本の動き】

操作はとってもシンプルで、キーボードひとつですぐに遊べます!(^v^*)

■ 基本操作

  • 移動:
      W A S D キー、または 矢印キー(↑←↓→) →スイカ(自機)を
    水面でスイスイと動かせます。
  • 種ショット:
     スペースキー → 前方に向かってスイカの種を連射します。
  • ゲームスタート / 次のウェーブ:
      スペースキー → タイトル画面、クリア時、
    ゲームオーバー時もスペースキーで進めます。

■ 画面のボタン

  • サウンドボタン(左上)
    クリックすると、ゲームボーイ風のレトロなBGMと効果音が鳴ります。
    ピコピコした音がとてもかわいいので、
    ぜひ音を出して遊んでみてくださいね♪
  • ランキングリセット(右側)
    ハイスコア記録をリセットしたいときに使えます。

■ 攻略のちょっとしたコツ(=v=*)

  • 終盤の赤い魚に要注意!
    魚が残り3匹以下になると、
    猛スピードで突っ込んでくる赤い魚が現れます。
    エサには見向きもしないので、しっかり見て避けてくださいね。(><;)
  • 丸いスイカを狙い撃ち!
    空腹の魚に直接種を当てても倒せません。
    上から流れてくる「丸いスイカ」を割って果肉をばら撒き、
    そちらへ意識を向けるのがポイントです。
    大きなスイカはちょっと固いので、2発当ててみてくださいね。
  • ウェーブが進むと自機がパワーアップ!
    WAVEをクリアするごとに、
    種ショットが2方向、3方向……最大5方向へと広がっていきます。

    一気にたくさんのスイカを割る爽快感を味わってみてください。(*^v^)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Watermelon Survival - DX Sound</title>
<style>
body {
margin: 0;
padding: 0;
background-color: #D4F1F4;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
color: #111;
font-family: sans-serif;
gap: 20px;
}
.sidebar {
width: 260px;
height: 760px;
background: rgba(255, 255, 255, 0.7);
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(24, 154, 180, 0.2);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.sidebar h2 {
margin-top: 0;
font-size: 22px;
color: #1E5945;
border-bottom: 2px solid #1E5945;
padding-bottom: 8px;
}
.sidebar h3 {
font-size: 16px;
color: #111;
margin-top: 20px;
margin-bottom: 10px;
background-color: rgba(117, 230, 218, 0.3);
padding: 5px 10px;
border-radius: 4px;
}
.sidebar p {
font-size: 14px;
line-height: 1.6;
margin-top: 0;
margin-bottom: 12px;
}
#currentScoreDisplay {
font-size: 48px;
font-weight: bold;
color: #FF2A35;
text-align: center;
margin: 10px 0 20px 0;
text-shadow: 1px 1px 2px rgba(0,0,0,0.1);
}
.key-btn {
display: inline-block;
background: #eee;
border: 1px solid #ccc;
border-radius: 4px;
padding: 2px 6px;
font-weight: bold;
box-shadow: 1px 1px 2px rgba(0,0,0,0.2);
}
.ui-btn {
width: 100%;
padding: 12px;
background-color: #FF2A35;
color: white;
font-weight: bold;
font-size: 16px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.ui-btn:hover { background-color: #CC222A; }
.ui-btn.active { background-color: #1E5945; }
#rankingList {
padding-left: 20px;
margin-top: 0;
font-size: 16px;
font-weight: bold;
color: #333;
}
#rankingList li { margin-bottom: 8px; }
#resetButton {
margin-top: 10px;
padding: 10px;
background-color: #1E5945;
color: white;
font-weight: bold;
border: none;
border-radius: 6px;
cursor: pointer;
}
#resetButton:hover { background-color: #154233; }
#triviaBox {
margin-top: 20px;
background: #fff;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #FF2A35;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
#triviaText { margin-bottom: 0; }
/* リアルな丸スイカのスタイル */
#realisticWatermelon {
width: 100%;
max-width: 160px;
margin: auto auto 10px auto;
filter: drop-shadow(5px 10px 15px rgba(0,0,0,0.2));
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
}
canvas {
background-color: #75E6DA;
box-shadow: 0 0 20px rgba(24, 154, 180, 0.5);
border-radius: 10px;
flex-shrink: 0;
}
</style>
</head>
<body>
<div class="sidebar">
<h2>SCORE</h2>
<div id="currentScoreDisplay">0</div>
<h3>サウンド (BGM・SE)</h3>
<button id="audioBtn" class="ui-btn">サウンド: OFF</button>
<h2>遊び方</h2>
<h3>移動</h3>
<p><span class="key-btn">W</span><span class="key-btn">A</span><span class="key-btn">S</span><span class="key-btn">D</span> で移動</p>
<h3>ショット</h3>
<p><span class="key-btn">Space</span> で発射。WAVEで進化。</p>
<h3>ルール</h3>
<p>スイカを撃ち砕き、魚たちに果肉を届けてください。赤い「突進魚」は避けてください。</p>
</div>
<canvas id="gameCanvas" width="600" height="800"></canvas>
<div class="sidebar">
<h2>ランキング</h2>
<ol id="rankingList"></ol>
<button id="resetButton">ランキングをリセット</button>
<div id="triviaBox">
<h3>🍉 スイカ雑学 (WAVE <span id="triviaWave">1</span>)</h3>
<p id="triviaText">スイカは果物ではなく「野菜(果実的野菜)」に分類されます。</p>
</div>
<!-- リアルな丸スイカ -->
<div id="realisticWatermelon">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="wmBase" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="#4a9c59"/>
<stop offset="70%" stop-color="#1e592f"/>
<stop offset="100%" stop-color="#0a2912"/>
</radialGradient>
<radialGradient id="wmShade" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="white" stop-opacity="0.35"/>
<stop offset="35%" stop-color="white" stop-opacity="0"/>
<stop offset="75%" stop-color="black" stop-opacity="0"/>
<stop offset="100%" stop-color="black" stop-opacity="0.8"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="url(#wmBase)" />
<g fill="none" stroke="#061f0d" stroke-width="5" opacity="0.85">
<path d="M50 2 C30 20, 30 80, 50 98 C70 80, 70 20, 50 2" stroke-width="4.5" />
<path d="M50 2 C10 15, 10 85, 50 98" stroke-width="5.5" />
<path d="M50 2 C90 15, 90 85, 50 98" stroke-width="5.5" />
<path d="M50 2 C-10 30, -10 70, 50 98" stroke-width="6" />
<path d="M50 2 C110 30, 110 70, 50 98" stroke-width="6" />
</g>
<circle cx="50" cy="50" r="48" fill="url(#wmShade)" />
</svg>
</div>
</div>
<script>
// --- サウンドエンジン ---
let audioCtx = null;
let isAudioEnabled = false;
let nextNoteTime = 0;
let noteIndex = 0;
const melody = [196.00, 261.63, 293.66, 329.63, 293.66, 261.63, 220.00, 261.63];
document.getElementById('audioBtn').addEventListener('click', (e) => {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
isAudioEnabled = !isAudioEnabled;
if (isAudioEnabled) {
if(audioCtx.state === 'suspended') audioCtx.resume();
e.target.innerText = 'サウンド: ON';
e.target.classList.add('active');
nextNoteTime = audioCtx.currentTime + 0.1;
} else {
e.target.innerText = 'サウンド: OFF';
e.target.classList.remove('active');
}
});
function playSE(type) {
if (!isAudioEnabled || !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 = 'square';
osc.frequency.setValueAtTime(1000, now);
osc.frequency.exponentialRampToValueAtTime(300, now + 0.1);
gain.gain.setValueAtTime(0.03, now);
gain.gain.linearRampToValueAtTime(0, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
} else if (type === 'hit') {
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(150, now);
osc.frequency.exponentialRampToValueAtTime(40, now + 0.3);
gain.gain.setValueAtTime(0.15, now);
gain.gain.linearRampToValueAtTime(0, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
} else if (type === 'destroy') {
osc.type = 'square';
osc.frequency.setValueAtTime(100, now);
osc.frequency.linearRampToValueAtTime(10, 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 === 'eat') {
osc.type = 'sine';
osc.frequency.setValueAtTime(880, now);
osc.frequency.setValueAtTime(1760, now + 0.05);
gain.gain.setValueAtTime(0.05, now);
gain.gain.linearRampToValueAtTime(0, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
}
}
function scheduleBGM() {
if (!isAudioEnabled || !audioCtx) return;
if (gameState !== 'playing') {
nextNoteTime = audioCtx.currentTime + 0.1;
return;
}
while (nextNoteTime < audioCtx.currentTime + 0.1) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
let freq = melody[noteIndex % melody.length];
if (level >= 3) freq *= 1.25;
if (level >= 5) freq *= 1.25;
osc.frequency.value = freq;
osc.connect(gain); gain.connect(audioCtx.destination);
gain.gain.setValueAtTime(0.02, nextNoteTime);
gain.gain.exponentialRampToValueAtTime(0.001, nextNoteTime + 0.15);
osc.start(nextNoteTime); osc.stop(nextNoteTime + 0.15);
nextNoteTime += 0.18;
noteIndex++;
}
}
// --- ゲーム本体 ---
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const keys = {};
window.addEventListener('keydown', e => {
if(["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].indexOf(e.code) > -1) e.preventDefault();
keys[e.code] = true;
});
window.addEventListener('keyup', e => keys[e.code] = false);
// 【変更】初期状態をタイトル画面に設定
let gameState = 'title';
let level = 1;
let baseFishCount = 15;
let clearDelay = 0;
let gameOverDelay = 0;
let score = 0;
let highScores = [];
try { highScores = JSON.parse(localStorage.getItem('watermelon_scores')) || []; } catch(e) {}
function saveScore() {
if (score > 0) {
highScores.push(score);
highScores.sort((a, b) => b - a);
highScores = highScores.slice(0, 5);
try { localStorage.setItem('watermelon_scores', JSON.stringify(highScores)); } catch(e) {}
}
updateRankingUI();
}
function updateRankingUI() {
const list = document.getElementById('rankingList');
list.innerHTML = '';
if (highScores.length === 0) {
list.innerHTML = '<li>記録がありません</li>';
} else {
highScores.forEach(s => {
const li = document.createElement('li'); li.textContent = `${s} pt`; list.appendChild(li);
});
}
}
document.getElementById('resetButton').addEventListener('click', () => {
highScores = [];
try { localStorage.removeItem('watermelon_scores'); } catch(e) {}
updateRankingUI();
});
const trivias = [
"スイカは果物ではなく「野菜(果実的野菜)」に分類されます。",
"スイカの赤い果肉には、トマトよりも多くの「リコピン」が含まれています。",
"特徴的な縞模様は、鳥に種を運んでもらうため目立つように進化したという説があります。",
"約90%が水分ですが、糖分やカリウムも豊富で熱中症対策にぴったりです。",
"中国などの海外では、スイカの種を煎っておやつとして食べる文化があります。",
"スイカを軽く叩いて音を確かめるのは、中に空洞がないかを聞き分けるためです。"
];
function updateTrivia() {
document.getElementById('triviaWave').innerText = level;
const index = (level - 1) % trivias.length;
document.getElementById('triviaText').innerText = trivias[index];
}
updateRankingUI();
updateTrivia();
const player = {
x: canvas.width / 2, y: canvas.height - 80, width: 60, height: 60, speed: 5.5, hp: 3, invincible: 0,
draw(time) {
if (this.invincible > 0 && Math.floor(time * 30) % 2 === 0) return;
ctx.save(); ctx.translate(this.x, this.y);
const grad = ctx.createLinearGradient(0, -this.height/2, 0, this.height/2);
grad.addColorStop(0, '#FF2A35'); grad.addColorStop(0.7, '#FF4D56'); grad.addColorStop(0.85, '#F2F2F2'); grad.addColorStop(1, '#1E5945');
ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(0, -this.height/2); ctx.lineTo(this.width/2, this.height/2 - 10); ctx.quadraticCurveTo(0, this.height/2 + 10, -this.width/2, this.height/2 - 10); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#111'; ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.arc(-15, 10, 3, 0, Math.PI * 2); ctx.arc(15, 10, 3, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
update() {
if (this.invincible > 0) this.invincible--;
if ((keys['ArrowLeft'] || keys['KeyA']) && this.x > this.width / 2) this.x -= this.speed;
if ((keys['ArrowRight'] || keys['KeyD']) && this.x < canvas.width - this.width / 2) this.x += this.speed;
if ((keys['ArrowUp'] || keys['KeyW']) && this.y > this.height / 2) this.y -= this.speed;
if ((keys['ArrowDown'] || keys['KeyS']) && this.y < canvas.height - this.height / 2) this.y += this.speed;
}
};
let seeds = [], shootTimer = 0, enemies = [], enemyTimer = 0;
let fishes = [], dashFishes = [], dashTimer = 0, fragments = [], particles = [], time = 0;
function drawWaterSurface(ctx, width, height, time) {
ctx.save();
ctx.fillStyle = '#75E6DA'; ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.beginPath();
for (let x = 0; x < width + 10; x += 10) ctx.lineTo(x, 30 + Math.sin(x * 0.01 + time * 0.5) * 15 + height * 0.1);
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.fill();
ctx.fillStyle = 'rgba(24, 154, 180, 0.1)'; ctx.beginPath();
for (let x = 0; x < width + 10; x += 10) ctx.lineTo(x, 10 + Math.sin(x * 0.02 + time * 1.2) * 10 + height * 0.3);
ctx.lineTo(width, height); ctx.lineTo(0, height); ctx.fill();
ctx.restore();
}
function spawnFish() {
const size = Math.random() * 15 + 10, edge = Math.floor(Math.random() * 4);
let fx, fy;
if (edge === 0) { fx = Math.random() * canvas.width; fy = -size * 2; }
else if (edge === 1) { fx = canvas.width + size * 2; fy = Math.random() * canvas.height; }
else if (edge === 2) { fx = Math.random() * canvas.width; fy = canvas.height + size * 2; }
else { fx = -size * 2; fy = Math.random() * canvas.height; }
return { x: fx, y: fy, size: size, speed: Math.random() * 1.5 + 1.5 + (level * 0.15), angle: Math.random() * Math.PI * 2, target: null, eatingTimer: 0, fullness: 0, satiated: false, wobblePhase: Math.random() * Math.PI * 2 };
}
function spawnDashFish() {
const size = 12 + Math.random() * 5, edge = Math.floor(Math.random() * 4);
let fx, fy;
if (edge === 0) { fx = Math.random() * canvas.width; fy = -size * 2; }
else if (edge === 1) { fx = canvas.width + size * 2; fy = Math.random() * canvas.height; }
else if (edge === 2) { fx = Math.random() * canvas.width; fy = canvas.height + size * 2; }
else { fx = -size * 2; fy = Math.random() * canvas.height; }
dashFishes.push({ x: fx, y: fy, size: size, speed: 8 + (level * 0.5), angle: Math.atan2(player.y - fy, player.x - fx) });
}
function startNextWave() {
level++;
fishes = []; for (let i = 0; i < baseFishCount + (level - 1) * 5; i++) fishes.push(spawnFish());
enemies = []; seeds = []; fragments = []; particles = []; dashFishes = []; dashTimer = 0;
if (player.hp < 3) player.hp++;
player.x = canvas.width / 2; player.y = canvas.height - 80;
updateTrivia(); gameState = 'playing'; shootTimer = 20;
}
function resetGame() {
level = 1; score = 0; player.hp = 3; player.x = canvas.width / 2; player.y = canvas.height - 80;
fishes = []; for (let i = 0; i < baseFishCount; i++) fishes.push(spawnFish());
enemies = []; seeds = []; fragments = []; particles = []; dashFishes = []; dashTimer = 0;
updateTrivia(); gameState = 'playing'; shootTimer = 20;
}
for (let i = 0; i < baseFishCount; i++) fishes.push(spawnFish());
function drawFish(ctx, fish, time) {
ctx.save(); ctx.translate(fish.x, fish.y); ctx.rotate(fish.angle);
let baseColor, tailColor, mouthColor;
if (fish.satiated) { baseColor = 'rgba(24, 154, 180, 0.9)'; tailColor = 'rgba(24, 154, 180, 0.6)'; mouthColor = '#189AB4'; }
else { baseColor = 'rgba(217, 250, 251, 0.7)'; tailColor = 'rgba(217, 250, 251, 0.4)'; mouthColor = '#D9FAFB'; }
ctx.fillStyle = baseColor; ctx.beginPath(); ctx.ellipse(0, 0, fish.size, fish.size * 0.4, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = tailColor; ctx.beginPath(); ctx.moveTo(-fish.size, 0); ctx.lineTo(-fish.size * 1.5, -fish.size * 0.5); ctx.lineTo(-fish.size * 1.5, fish.size * 0.5); ctx.fill();
if (fish.eatingTimer > 0) {
ctx.fillStyle = mouthColor;
const mouthSize = fish.size * 0.4 * (Math.sin(time * 30) * 0.5 + 0.5);
ctx.beginPath(); ctx.moveTo(fish.size, 0); ctx.lineTo(fish.size * 0.7, -mouthSize); ctx.quadraticCurveTo(fish.size * 0.9, 0, fish.size * 0.7, mouthSize); ctx.fill();
}
ctx.restore();
}
function updateFish(fish, time) {
if (fish.eatingTimer > 0) {
fish.eatingTimer--; if (fish.eatingTimer <= 0) fish.target = null; return;
}
if (fish.satiated) {
fish.angle += (Math.random() - 0.5) * 0.1; fish.x += Math.cos(fish.angle) * (fish.speed * 0.5); fish.y += Math.sin(fish.angle) * (fish.speed * 0.5);
} else if (fish.target && fish.target.life > 0 && !fish.target.ate) {
const dx = fish.target.x - fish.x, dy = fish.target.y - fish.y;
fish.angle = Math.atan2(dy, dx); fish.x += Math.cos(fish.angle) * (fish.speed * 2.0); fish.y += Math.sin(fish.angle) * (fish.speed * 2.0);
if (Math.hypot(dx, dy) < fish.size) {
fish.eatingTimer = 20; fish.target.ate = true; fish.fullness++;
playSE('eat');
if (fish.fullness >= 2) fish.satiated = true;
score += 5;
}
} else {
const dx = player.x - fish.x, dy = player.y - fish.y;
fish.angle = Math.atan2(dy, dx) + Math.sin(time * 2 + fish.wobblePhase) * 0.6;
fish.x += Math.cos(fish.angle) * fish.speed; fish.y += Math.sin(fish.angle) * fish.speed;
if (Math.hypot(dx, dy) < fish.size + 15 && player.invincible <= 0) {
player.hp--; player.invincible = 60; createSplash(player.x, player.y, 30); playSE('hit');
if (player.hp <= 0) { gameState = 'gameover'; gameOverDelay = 60; saveScore(); }
}
}
if (fish.x < -fish.size * 3) fish.x = canvas.width + fish.size * 3;
if (fish.x > canvas.width + fish.size * 3) fish.x = -fish.size * 3;
if (fish.y < -fish.size * 3) fish.y = canvas.height + fish.size * 3;
if (fish.y > canvas.height + fish.size * 3) fish.y = -fish.size * 3;
}
function spawnFragments(x, y, radius) {
for (let i = 0; i < 12; i++) {
const angle = Math.random() * Math.PI * 2, speed = Math.random() * 6 + 2;
fragments.push({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, size: Math.random() * 8 + 4, color: Math.random() < 0.6 ? '#FF2A35' : (Math.random() < 0.8 ? '#F2F2F2' : '#1E5945'), life: 1.0, decay: Math.random() * 0.005 + 0.002, ate: false });
}
return 12;
}
function spawnEnemy() {
const radius = Math.random() * 20 + 20, maxHp = radius > 33 ? 2 : 1;
enemies.push({ x: Math.random() * (canvas.width - radius * 2) + radius, y: -radius, radius: radius, speed: Math.random() * 2.0 + 1.5 + (level * 0.2), rotation: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.1, hp: maxHp, maxHp: maxHp });
}
function createSplash(x, y, radius) {
for (let i = 0; i < 20; i++) particles.push({ x: x, y: y, vx: (Math.random() - 0.5) * 12, vy: (Math.random() - 0.5) * 12, radius: Math.random() * 5 + 2, life: 1.0, decay: Math.random() * 0.02 + 0.02 });
}
function drawUI() {
ctx.fillStyle = '#111'; ctx.font = 'bold 24px sans-serif'; ctx.fillText(`WAVE: ${level}`, 20, 40);
ctx.fillText(`HP: `, 20, 75);
for(let i=0; i<player.hp; i++) {
ctx.fillStyle = '#FF2A35'; ctx.beginPath(); ctx.arc(100 + i * 25, 68, 8, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#1E5945'; ctx.beginPath(); ctx.arc(100 + i * 25, 68, 8, Math.PI, 0); ctx.fill();
}
}
function updateAndDraw() {
time += 0.01;
scheduleBGM();
drawWaterSurface(ctx, canvas.width, canvas.height, time);
document.getElementById('currentScoreDisplay').innerText = score;
// 【追加】タイトル画面の入力処理
if (gameState === 'title') {
if (keys['Space']) resetGame();
} else if (gameState === 'gameclear') {
if (clearDelay > 0) clearDelay--;
else if (keys['Space']) startNextWave();
} else if (gameState === 'gameover') {
if (gameOverDelay > 0) gameOverDelay--;
else if (keys['Space']) resetGame();
}
if (gameState === 'playing') {
player.update();
if (keys['Space'] && shootTimer <= 0) {
const speed = 14; let angles = [];
if (level === 1) angles = [0];
else if (level === 2) angles = [-0.15, 0.15];
else if (level === 3) angles = [0, -0.2, 0.2];
else if (level === 4) angles = [-0.1, 0.1, -0.3, 0.3];
else angles = [0, -0.15, 0.15, -0.3, 0.3];
for (let a of angles) seeds.push({ x: player.x, y: player.y - player.height/2, vx: Math.sin(a) * speed, vy: -Math.cos(a) * speed, angle: a });
playSE('shoot'); shootTimer = 10;
}
if (shootTimer > 0) shootTimer--;
enemyTimer++; if (enemyTimer > 35) { spawnEnemy(); enemyTimer = 0; }
let hungryCount = 0;
for (let fish of fishes) if (!fish.satiated) hungryCount++;
if (hungryCount > 0 && hungryCount <= 3) {
dashTimer++;
if (dashTimer > Math.max(30, 80 - (level * 5))) { spawnDashFish(); dashTimer = 0; }
} else dashTimer = 0;
if (hungryCount === 0 && fishes.length > 0) { gameState = 'gameclear'; clearDelay = 60; }
}
for (let i = fragments.length - 1; i >= 0; i--) {
let f = fragments[i];
f.x += f.vx; f.y += f.vy; f.vx *= 0.98; f.vy *= 0.98; f.life -= f.decay;
if (f.life <= 0 || f.ate) { fragments.splice(i, 1); continue; }
ctx.save(); ctx.translate(f.x, f.y); ctx.rotate(f.x * 0.05);
ctx.fillStyle = `rgba(${f.color === '#FF2A35' ? '255, 42, 53' : (f.color === '#F2F2F2' ? '242, 242, 242' : '30, 89, 69')}, ${f.life})`;
ctx.beginPath(); ctx.moveTo(0, -f.size / 2); ctx.lineTo(f.size / 2, f.size / 2); ctx.lineTo(-f.size / 2, f.size / 2); ctx.fill(); ctx.restore();
}
for (let fish of fishes) {
// タイトル画面でも魚は更新するが、プレイヤーは襲わないようにする
if (gameState === 'playing' || gameState === 'gameover' || gameState === 'gameclear') {
updateFish(fish, time);
} else if (gameState === 'title') {
// タイトル画面中はゆっくり直進させるだけ
fish.x += Math.cos(fish.angle) * (fish.speed * 0.5);
fish.y += Math.sin(fish.angle) * (fish.speed * 0.5);
if (fish.x < -fish.size * 3) fish.x = canvas.width + fish.size * 3;
if (fish.x > canvas.width + fish.size * 3) fish.x = -fish.size * 3;
if (fish.y < -fish.size * 3) fish.y = canvas.height + fish.size * 3;
if (fish.y > canvas.height + fish.size * 3) fish.y = -fish.size * 3;
}
drawFish(ctx, fish, time);
}
if (gameState !== 'gameover' && gameState !== 'title') {
for (let i = dashFishes.length - 1; i >= 0; i--) {
let df = dashFishes[i];
df.x += Math.cos(df.angle) * df.speed; df.y += Math.sin(df.angle) * df.speed;
ctx.save(); ctx.translate(df.x, df.y); ctx.rotate(df.angle);
ctx.fillStyle = 'rgba(255, 60, 60, 0.9)'; ctx.beginPath(); ctx.ellipse(0, 0, df.size, df.size * 0.35, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255, 60, 60, 0.6)'; ctx.beginPath(); ctx.moveTo(-df.size, 0); ctx.lineTo(-df.size * 1.5, -df.size * 0.5); ctx.lineTo(-df.size * 1.5, df.size * 0.5); ctx.fill(); ctx.restore();
if (Math.hypot(player.x - df.x, player.y - df.y) < df.size + 15 && player.invincible <= 0) {
player.hp--; player.invincible = 60; createSplash(player.x, player.y, 30); playSE('hit');
if (player.hp <= 0) { gameState = 'gameover'; gameOverDelay = 60; saveScore(); }
}
if (df.x < -100 || df.x > canvas.width + 100 || df.y < -100 || df.y > canvas.height + 100) dashFishes.splice(i, 1);
}
}
if (gameState === 'playing' || gameState === 'title') {
player.draw(time);
}
for (let i = seeds.length - 1; i >= 0; i--) {
let s = seeds[i]; s.x += s.vx; s.y += s.vy;
ctx.save(); ctx.translate(s.x, s.y); ctx.rotate(s.angle);
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI); ctx.lineTo(0, -8); ctx.fillStyle = '#111'; ctx.fill();
ctx.lineWidth = 1.5; ctx.strokeStyle = '#F2F2F2'; ctx.stroke();
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.beginPath(); ctx.arc(-1.5, -1.5, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.restore();
if (s.y < -20 || s.x < -20 || s.x > canvas.width + 20) seeds.splice(i, 1);
}
for (let i = enemies.length - 1; i >= 0; i--) {
let e = enemies[i];
e.y += e.speed; e.rotation += e.rotSpeed;
ctx.save(); ctx.translate(e.x, e.y); ctx.rotate(e.rotation);
ctx.fillStyle = '#1E5945'; ctx.beginPath(); ctx.arc(0, 0, e.radius, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#111'; ctx.lineWidth = 4; ctx.beginPath();
for(let j = 0; j < 3; j++) { ctx.moveTo(-e.radius + 5, -e.radius/2 + j*e.radius/1.5); ctx.bezierCurveTo(0, -e.radius/2 + j*e.radius/1.5 + 15, 0, -e.radius/2 + j*e.radius/1.5 - 15, e.radius - 5, -e.radius/2 + j*e.radius/1.5); }
ctx.stroke();
if (e.hp < e.maxHp) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-e.radius * 0.4, -e.radius * 0.3); ctx.lineTo(-e.radius * 0.1, e.radius * 0.1); ctx.lineTo(e.radius * 0.3, 0); ctx.lineTo(e.radius * 0.6, e.radius * 0.4); ctx.stroke();
}
ctx.restore();
let destroyed = false;
if (gameState === 'playing') {
for (let j = seeds.length - 1; j >= 0; j--) {
let s = seeds[j];
if (Math.hypot(s.x - e.x, s.y - e.y) < e.radius + 4) {
e.hp--; seeds.splice(j, 1);
if (e.hp <= 0) {
playSE('destroy');
createSplash(e.x, e.y, e.radius);
const fragCount = spawnFragments(e.x, e.y, e.radius);
let newFrags = fragments.slice(-fragCount); let fIdx = 0;
for(let fish of fishes) {
if(!fish.satiated && fish.eatingTimer <= 0) { fish.target = newFrags[fIdx % newFrags.length]; fIdx++; }
}
score += 10; destroyed = true;
} else {
playSE('hit');
createSplash(e.x, e.y, e.radius / 2); score += 2;
}
break;
}
}
}
if (destroyed || e.y > canvas.height + e.radius) enemies.splice(i, 1);
}
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx; p.y += p.vy; p.life -= p.decay;
if (p.life <= 0) { particles.splice(i, 1); continue; }
ctx.fillStyle = `rgba(255, 59, 63, ${p.life})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fill();
}
drawUI();
// 【追加】タイトル画面の描画
if (gameState === 'title') {
ctx.fillStyle = 'rgba(0, 42, 42, 0.4)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFF'; ctx.textAlign = 'center';
ctx.font = 'bold 44px sans-serif'; ctx.fillText('WATERMELON SURVIVAL', canvas.width/2, canvas.height/2 - 20);
ctx.fillStyle = '#FF4D56'; ctx.font = 'bold 22px sans-serif';
if (Math.floor(time * 2) % 2 === 0) {
ctx.fillText('- PRESS SPACE TO START -', canvas.width/2, canvas.height/2 + 40);
}
ctx.textAlign = 'left';
}
if (gameState === 'gameover') {
ctx.fillStyle = 'rgba(0, 42, 42, 0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFF'; ctx.textAlign = 'center'; ctx.font = 'bold 48px sans-serif'; ctx.fillText('GAME OVER', canvas.width/2, canvas.height/2 - 20);
ctx.font = '24px sans-serif'; ctx.fillText(`SCORE: ${score} (WAVE ${level})`, canvas.width/2, canvas.height/2 + 30);
if (gameOverDelay <= 0) { ctx.fillStyle = '#FF4D56'; ctx.font = 'bold 20px sans-serif'; ctx.fillText('- PRESS SPACE TO RETRY -', canvas.width/2, canvas.height/2 + 80); }
ctx.textAlign = 'left';
}
if (gameState === 'gameclear') {
ctx.fillStyle = 'rgba(212, 241, 244, 0.8)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1E5945'; ctx.textAlign = 'center'; ctx.font = 'bold 48px sans-serif'; ctx.fillText('WAVE CLEAR!', canvas.width/2, canvas.height/2 - 40);
ctx.font = 'bold 28px sans-serif'; ctx.fillText(`SCORE: ${score}`, canvas.width/2, canvas.height/2 + 10);
ctx.font = '20px sans-serif'; ctx.fillText('お見事!次も油断せずにいきましょう。', canvas.width/2, canvas.height/2 + 70);
if (clearDelay <= 0) {
ctx.fillStyle = '#FF2A35'; ctx.font = 'bold 22px sans-serif';
if (Math.floor(time * 2) % 2 === 0) ctx.fillText('- PRESS SPACE TO NEXT WAVE -', canvas.width/2, canvas.height/2 + 130);
}
ctx.textAlign = 'left';
}
requestAnimationFrame(updateAndDraw);
}
updateAndDraw();
</script>
</body>
</html>


■ 今日の学び

今回の「WATERMELON SURVIVAL」は、
当ブログで紹介する 122個目 のゲームになりました。

実はこの作品、以前公開した #114「スイカピンボール」を作ったすぐ後に、
その勢いのまま生まれたゲームなんです。(^v^*)

スイカの日に合わせてピンボールを作ったところ、
スイカへの愛があふれすぎてしまい…… どうしてもひとつでは収まりきらず、
もう一作作ってしまいました。(笑)

ジャンルとしてはアクションシューティングですが、
「敵を倒す」のではなく、 「迫りくる空腹のお魚たちに、
スイカを砕いて果肉を分けてあげる」 という少し変わった設定にしています。

お魚たちがみんなお腹いっぱいになると、
平和にゲームクリアになる仕組みは、
個人的にもとても気に入っているポイントです。

さてさて、ブログで紹介できたゲームはこれで 122 個目ですが、
実は手元の “まだ記事にできていないゲームたち” が
毎日すごいペースで増え続けておりまして……。

とうとう未公開のゲーム数がブログ記事の数を超え、
合計で 250個近く になってしまいました。( ꒪⌓꒪)

以前の記事で 「100個のゲームを超えたので、次は1,000個を目指してみます」
と書きましたが、
気づけばもう目標の 1/4 まで来ています。

ほんとうに、塵も積もればなんとやらですね。ᕙ( ˙-˙ )ᕗ

最近は、記事が追いつかないゲームたちを
動画にして先に紹介したりしています。

ブログ記事にする順番としては、
今のところ 「まだ X(旧Twitter)に動画を出していないゲーム」 から
優先して書いている状態です。

もし、X の動画を見て
「このゲームで実際に遊んでみたい!」
「どうやって作ったのか裏側を知りたい!」 というものがありましたら、

X や note のコメント欄でお気軽に教えてくださいね。

リクエストをいただけると、
みーの執筆スピードがぐんっと上がるかもしれません!꜀( ꜆ᐢ. ̫.ᐢ)꜆

これからもどんどん新しい実験をしていきますので、
引き続きよろしくお願いいたします。(^v^

■ 次回は、磯の駆け引きをやさしく再現した「フカセ釣りゲーム」です。

「水中のリアルな動きや駆け引きを表現してみたい」と思い、
奥深い フカセ釣り をテーマにしたシミュレーションゲームを作ってみました。

撒き餌をふわりと漂わせて、元気なエサ取りの魚たちを上手にかわしながら、

深い海の底でじっと待つ大物グレ(メジナ)を狙い撃ちます。

潮の流れを読み、仕掛けを同調させていくと、
ウキがすっと消し込む瞬間が訪れます。

その一瞬は、思わず手に汗握る面白さになっています。(=v=*)

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


次の実験はこちら
撒き餌と同調の駆け引き
「極・フカセ釣りゲーム」



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

 

~このブログについて~

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

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


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

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

続きを読む