実験#29:広がるつながる消えていく(シーグラスチェイン)

■29個目「シーグラスチェイン」Sea Glass Chain

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

大好きなシーグラスを、またまたゲームにしてみました。

いつもは「こんなゲームが作りたい」とAIへ提案しているのですが、
今回は逆に、AIからゲームの基礎アイデアをもらってスタートしました。

「自分が夢中で遊べるゲームを作りたい!」とAIと相談しながら形にした作品です。

操作はクリック一回だけの、とっても簡単なゲーム。
気づけば「次もう一回!」と止まらなくなる中毒性があります。

ぜひ一度は体験してほしい、楽しいゲームになりました。ヽ(*^o^*)ノ

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

(^^*)今日もゲーム作ります。html形式で動かせるゲーム。
中毒性のあるゲーム作りたいね。

(AI)いらっしゃいませ!今日も新作の開発ですね。
「中毒性」という言葉、クリエイターの心をくすぐる最高のスパイスです。

シンプルだけど、ついつい「もう一回!」と指が動いてしまう……
そんな、メモ帳アプリでもサクサク動くHTMLゲームのアイデアを練ってみました。

●「増える」の視覚的快感:連鎖爆発パズル
中毒ポイント:1タップで画面全体が埋め尽くされる爽快感。
内容:粒子をタップして爆発 → 触れた粒子が次々誘爆していくパズル。

(^_^) 連鎖爆発パズルいいですね。粒子をシーグラスで再現できるかな。

(AI)シーグラスをモチーフにするのは視覚的にもすごく美しいですね!
透け感とパステルカラーが連鎖して弾ける様子は、まさに中毒性のある心地よさです。

●今日の豆知識
シーグラスが「海の宝石」と呼ばれるようになるまで、
20〜30年も波に揺られているそうです。
このゲームも、1回のタップでその「時間の結晶」が
一気に弾けるような儚さを表現しましょう。

(^^*)
いい感じです。爆発した時にガラスの破片を散らしたいです。
あと、神秘的なBGMと効果音を入れたいね。

(AI)
深海では音は空気中より約4.5倍速く伝わるんですよ。
連鎖がどんどん加速していく感じも、水の性質をイメージして調整してみました。

ブラウザの仕様上、最初のクリックで音が有効になります。
シーグラスが「チリン、チリン」と連鎖していく感覚、ぜひ楽しんでください。

■ 実際の動作

 まずは、AIとの相談で作ってもらったゲームの初期状態です
 シーグラスがふわふわと漂っていて、好きな場所をクリックすると
 そこから連鎖がスタートします。
(画像:AIが最初に作った状態)

 こちらが今回の仕上がり状態です。
 レアな赤色やラベンダー色のシーグラスも追加されました。
 背景は波をイメージしていて、シーグラスはランダムに漂っています。

(画像:スタートの状態)

 ここが一番気持ちいい瞬間です。(*>v<)”
 クリックすると、そこから連鎖が広がっていきます。
 円の届く範囲にシーグラスがあると誘爆していきます。
 レアな色は、爆発の円が通常より 1.2 倍大きくなります。

(画像:連鎖の状態)

 クリア条件のシーグラスを消すと次のステージへ進みます。
 ステージが進むごとに、クリアに必要な数が少しずつ増えていきます。

(画像:クリアした状態)


 最後に、実際に拾ったシーグラスや貝殻の写真です。
 この“海の宝物”たちが、今回のゲームのモチーフになっています。

(画像:おはじきのシーグラス)

(画像:拾った貝殻)

この色がゲームに使われています

(画像:シーグラスの写真)

■ コードのポイント

今回の記事では、AI と相談しながら作った
「シーグラスチェイン」の HTML コードを公開します。

ブラウザだけで動く、とてもシンプルなゲームです。
コードといっても難しい部分はありません。
貼り付けて保存するだけで動くので、気軽に試してみてください。

PC の「メモ帳」(※Windows の場合)を開き、 下のコードをコピーして貼り付け、

「chein.html」など 好きな名前で保存してください。
保存したファイルをブラウザで開くだけで遊べます。

【操作方法】
1. 画面を1回だけタップ(クリック)します。

2. タップした場所に「波紋(爆発)」が発生します。

3. その波紋に触れたシーグラスが、キラキラと輝きながら自分も波紋を発します。

4. 次々と連鎖して、画面上の何割を消せたかでスコアが決まります。


▼ PC版コードはこちら

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sea Glass Chain - Wavy & Level Up (Lavender Added)</title>
<style>
body { margin: 0; overflow: hidden; background: #001a33; font-family: sans-serif; user-select: none; }
canvas { display: block; cursor: crosshair; }
#ui {
position: absolute; top: 10px; left: 10px; color: white;
pointer-events: none; text-shadow: 1px 1px 2px black;
}
.btn {
position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
padding: 12px 24px; font-size: 20px; background: rgba(0, 150, 255, 0.3);
color: white; border: 2px solid white; border-radius: 8px; cursor: pointer;
display: none; pointer-events: auto; transition: 0.2s;
}
.btn:hover { background: rgba(0, 150, 255, 0.6); box-shadow: 0 0 10px white; }
</style>
</head>
<body>
<div id="ui">
<h2>Sea Glass Chain - Level <span id="levelNum">1</span></h2>
<p>画面をタップして連鎖スタート!(レアな赤・ピンク・ラベンダーは波紋が広範囲)</p>
<p id="targetScore" style="color: #ffcc00; font-weight: bold;">クリア目標: 50 / 150</p>
<p id="score">現在の連鎖: 0</p>
</div>
<button id="nextBtn" class="btn" onclick="nextLevel()">クリア!次のレベルへ</button>
<button id="retryBtn" class="btn" onclick="retryLevel()" style="background: rgba(255, 50, 50, 0.3);">失敗…やり直す</button>
<canvas id="gameCanvas"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const levelEl = document.getElementById('levelNum');
const targetEl = document.getElementById('targetScore');
const scoreEl = document.getElementById('score');
const nextBtn = document.getElementById('nextBtn');
const retryBtn = document.getElementById('retryBtn');
let width, height;
let particles = [], ripples = [], shards = [];
let gameStarted = false, gameEnded = false;
let chainCount = 0;
// レベル管理
let level = 1;
const MAX_PARTICLES = 150;
let targetCount = 50;
// 音声設定
let audioCtx = null;
function initAudio() {
if (audioCtx) {
if(audioCtx.state === 'suspended') audioCtx.resume();
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
startBGM();
}
function startBGM() {
const notes = [440, 523.25, 587.33, 659.25, 783.99];
setInterval(() => {
if (!gameStarted || gameEnded) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(notes[Math.floor(Math.random() * notes.length)], audioCtx.currentTime);
gain.gain.setValueAtTime(0, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.04, audioCtx.currentTime + 1);
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 2.5);
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 2.5);
}, 3000);
}
function playChainSound(count, isRare) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const baseFreq = isRare ? 1200 : 880; // レアは少し高い音
osc.type = 'triangle';
osc.frequency.setValueAtTime(baseFreq + (count * 15), audioCtx.currentTime);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
}
// --- ゲームクラス ---
const NORMAL_COLORS = ['#ADFF2F', '#87CEEB', '#FFD700', '#F0F8FF', '#40E0D0'];
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.vx = (Math.random() - 0.5) * (0.8 + level * 0.1);
this.vy = (Math.random() - 0.5) * (0.8 + level * 0.1);
this.size = Math.random() * 8 + 6;
this.active = true;
this.borderRadius = [Math.random()*10, Math.random()*10, Math.random()*10, Math.random()*10];
// --- 追加部分:レア度の確率計算 ---
const typeRand = Math.random();
if (typeRand < 0.10) { // 全体の10%がレア
this.isRare = true;
if (typeRand < 0.01) {
this.color = '#FF4500'; // 1%の確率で「赤」
} else if (typeRand < 0.025) {
this.color = '#FF69B4'; // 1.5%の確率で「ピンク」
} else {
this.color = '#E6E6FA'; // 7.5%の確率で「ラベンダー」
}
} else { // 残り90%はノーマル
this.isRare = false;
this.color = NORMAL_COLORS[Math.floor(Math.random() * NORMAL_COLORS.length)];
}
}
update() {
if (!this.active) return;
this.x += this.vx; this.y += this.vy;
if (this.x < 0 || this.x > width) this.vx *= -1;
if (this.y < 0 || this.y > height) this.vy *= -1;
}
draw() {
if (!this.active) return;
ctx.fillStyle = this.color + "CC";
if(this.isRare) {
ctx.shadowColor = this.color;
ctx.shadowBlur = this.color === '#FF4500' ? 15 : 10; // 赤は強めに光る
} else {
ctx.shadowBlur = 0;
}
ctx.beginPath();
ctx.roundRect(this.x - this.size/2, this.y - this.size/2, this.size, this.size, this.borderRadius);
ctx.fill();
ctx.shadowBlur = 0; // リセット
}
explode() {
this.active = false;
playChainSound(chainCount, this.isRare);
// 赤色は破片を少し多めに散らす
let sc = 6;
if (this.isRare) sc = this.color === '#FF4500' ? 12 : 10;
for (let i = 0; i < sc; i++) {
shards.push(new Shard(this.x, this.y, this.color));
}
}
}
class Ripple {
// 色を受け取れるように変更
constructor(x, y, isRare = false, color = '#FFFFFF') {
this.x = x; this.y = y;
this.radius = 0;
this.isRare = isRare;
this.color = color;
// レアなら寿命を1.2倍にして、より遠くまで広がるようにする
this.life = isRare ? 120 : 100;
this.active = true;
}
update() {
this.radius += 2.0;
this.life -= 1.5;
if (this.life <= 0) this.active = false;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
const alpha = Math.max(0, this.life / (this.isRare ? 120 : 100));
// 色に応じて波紋の色を変化
if (this.isRare) {
if (this.color === '#FF4500') {
ctx.strokeStyle = `rgba(255, 100, 100, ${alpha})`;
} else if (this.color === '#FF69B4') {
ctx.strokeStyle = `rgba(255, 150, 200, ${alpha})`;
} else if (this.color === '#E6E6FA') {
ctx.strokeStyle = `rgba(200, 180, 255, ${alpha})`; // ラベンダー色の波紋
} else {
ctx.strokeStyle = `rgba(255, 100, 150, ${alpha})`;
}
ctx.lineWidth = 3;
} else {
ctx.strokeStyle = `rgba(200, 240, 255, ${alpha})`;
ctx.lineWidth = 1.5;
}
ctx.stroke();
}
}
class Shard {
constructor(x, y, color) {
this.x = x; this.y = y; this.color = color;
this.size = Math.random() * 3 + 1;
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 4 + 1;
this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed;
this.gravity = 0.05; this.life = 100; this.active = true;
}
update() {
this.x += this.vx; this.y += this.vy;
this.vy += this.gravity; this.life -= 2.0;
if (this.life <= 0) this.active = false;
}
draw() {
ctx.fillStyle = this.color;
ctx.globalAlpha = Math.max(0, this.life / 100);
ctx.fillRect(this.x, this.y, this.size, this.size);
ctx.globalAlpha = 1;
}
}
function initLevel() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
// レベルに応じた目標値の計算(最大90%)
const ratio = Math.min(0.3 + (level * 0.05), 0.9);
targetCount = Math.floor(MAX_PARTICLES * ratio);
levelEl.innerText = level;
targetEl.innerText = `クリア目標: ${targetCount} / ${MAX_PARTICLES}`;
scoreEl.innerText = `現在の連鎖: 0`;
particles = Array.from({ length: MAX_PARTICLES }, () => new Particle());
ripples = []; shards = [];
chainCount = 0;
gameStarted = false; gameEnded = false;
nextBtn.style.display = 'none';
retryBtn.style.display = 'none';
}
// 波打つ背景の描画
function drawWaves() {
const time = Date.now() * 0.001;
// 奥の波
ctx.fillStyle = 'rgba(0, 40, 80, 0.4)';
ctx.beginPath();
ctx.moveTo(0, height);
for(let x = 0; x <= width; x += 30) {
let y = height * 0.4 + Math.sin(x * 0.003 + time) * 60 + Math.sin(x * 0.01 - time * 0.5) * 20;
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.fill();
// 手前の波
ctx.fillStyle = 'rgba(0, 60, 100, 0.3)';
ctx.beginPath();
ctx.moveTo(0, height);
for(let x = 0; x <= width; x += 30) {
let y = height * 0.6 + Math.sin(x * 0.004 - time * 0.8) * 40;
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.fill();
}
function animate() {
// 背景クリア
ctx.fillStyle = '#001a33';
ctx.fillRect(0, 0, width, height);
// 背景の波を描画
drawWaves();
particles.forEach(p => {
p.update(); p.draw();
ripples.forEach(r => {
const dist = Math.hypot(p.x - r.x, p.y - r.y);
if (p.active && dist < r.radius + p.size && dist > r.radius - 20) {
p.explode();
// 爆発した粒子の色を波紋に引き継がせる
ripples.push(new Ripple(p.x, p.y, p.isRare, p.color));
chainCount++;
scoreEl.innerText = `現在の連鎖: ${chainCount}`;
}
});
});
ctx.globalCompositeOperation = 'lighter';
shards = shards.filter(s => s.active);
shards.forEach(s => { s.update(); s.draw(); });
ctx.globalCompositeOperation = 'source-over';
ripples = ripples.filter(r => r.active);
ripples.forEach(r => { r.update(); r.draw(); });
// ゲーム終了判定
if (gameStarted && !gameEnded && ripples.length === 0 && shards.length === 0) {
gameEnded = true;
if (chainCount >= targetCount) {
nextBtn.style.display = 'block';
} else {
retryBtn.style.display = 'block';
}
}
requestAnimationFrame(animate);
}
window.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
initAudio();
if (!gameStarted) {
// 最初のタップ波紋は白色
ripples.push(new Ripple(e.clientX, e.clientY, false, '#FFFFFF'));
gameStarted = true;
}
});
function nextLevel() { level++; initLevel(); }
function retryLevel() { initLevel(); }
window.addEventListener('resize', initLevel);
initLevel(); animate();
</script>
</body>
</html>


■ スマホ版コード(タッチ操作対応)

この「シーグラスチェイン」は、AIとの相談で
スマホでも遊べるようにしたタッチ操作バージョンも作っていました。(^^*)

画面を指でタップすると、その場所から波紋(爆発)が広がります。
PC版と同じように、シーグラスが連鎖していく気持ちよさを楽しめます。

【スマホでの操作方法】
・画面を1回タップ → 波紋(爆発)が発生
・波紋に触れたシーグラスが誘爆していきます
・クリア条件の数を消すと次のステージへ進みます

▼ スマホ版コードはこちら
▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Sea Glass Chain - Perfect Control</title>
<style>
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #001a33; font-family: sans-serif; user-select: none; -webkit-user-select: none; touch-action: none; }
canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
#ui {
position: absolute; top: 10px; left: 10px; color: white;
pointer-events: none; text-shadow: 1px 1px 3px rgba(0,0,0,0.8);
z-index: 10;
}
h2 { margin: 0 0 5px 0; font-size: 20px; }
p { margin: 2px 0; font-size: 14px; }
#targetScore { font-size: 16px; }
/* 点滅するスタートメッセージ */
.blink { animation: blinker 1.5s linear infinite; color: #87CEEB; font-weight: bold; font-size: 16px; margin-top: 5px; }
@keyframes blinker { 50% { opacity: 0.3; } }
.btn {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 15px 30px; font-size: 20px; font-weight: bold; background: rgba(0, 150, 255, 0.4);
color: white; border: 2px solid rgba(255,255,255,0.8); border-radius: 10px; cursor: pointer;
display: none; pointer-events: auto; z-index: 20; box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
#portrait-warning {
display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #001a33; color: white; z-index: 9999;
text-align: center; flex-direction: column; justify-content: center; align-items: center;
}
#portrait-warning svg { width: 50px; height: 50px; margin-bottom: 20px; animation: rotatePhone 2s infinite ease-in-out; }
@keyframes rotatePhone {
0% { transform: rotate(0deg); }
50% { transform: rotate(-90deg); }
100% { transform: rotate(-90deg); }
}
@media screen and (orientation: portrait) {
#portrait-warning { display: flex; }
}
</style>
</head>
<body>
<div id="portrait-warning">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect><line x1="12" y1="18" x2="12.01" y2="18"></line></svg>
<h3>スマホを横向きにしてね</h3>
<p>画面いっぱいの海を楽しむための設定です</p>
</div>
<div id="ui">
<h2>Sea Glass Chain - Level <span id="levelNum">1</span></h2>
<p id="startMsg" class="blink">画面をタップして連鎖スタート!</p>
<p id="targetScore" style="color: #ffcc00; font-weight: bold;">クリア目標: 0 / 0</p>
<p id="score">現在の連鎖: 0</p>
</div>
<button id="nextBtn" class="btn">次のレベルへ</button>
<button id="retryBtn" class="btn" style="background: rgba(255, 50, 50, 0.4);">やり直す</button>
<canvas id="gameCanvas"></canvas>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const levelEl = document.getElementById('levelNum');
const targetEl = document.getElementById('targetScore');
const scoreEl = document.getElementById('score');
const startMsg = document.getElementById('startMsg');
const nextBtn = document.getElementById('nextBtn');
const retryBtn = document.getElementById('retryBtn');
let width, height;
let particles = [], ripples = [], shards = [];
let gameStarted = false, gameEnded = false;
let chainCount = 0;
let level = 1;
let currentMaxParticles = 150;
let targetCount = 50;
let SCALE = 1;
let audioCtx = null;
function initAudio() {
try {
if (audioCtx) {
if(audioCtx.state === 'suspended') audioCtx.resume();
return;
}
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) {
audioCtx = new AC();
startBGM();
}
} catch (e) {}
}
function startBGM() {
const notes = [440, 523.25, 587.33, 659.25, 783.99];
setInterval(() => {
if (!gameStarted || gameEnded || !audioCtx) return;
try {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(notes[Math.floor(Math.random() * notes.length)], audioCtx.currentTime);
gain.gain.setValueAtTime(0, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.04, audioCtx.currentTime + 1);
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 2.5);
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 2.5);
} catch(e) {}
}, 3000);
}
function playChainSound(count, isRare) {
if (!audioCtx) return;
try {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const baseFreq = isRare ? 1200 : 880;
osc.type = 'triangle';
osc.frequency.setValueAtTime(Math.min(baseFreq + (count * 15), 3000), audioCtx.currentTime);
gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.3);
osc.connect(gain).connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.3);
} catch(e) {}
}
const NORMAL_COLORS = ['#ADFF2F', '#87CEEB', '#FFD700', '#F0F8FF', '#40E0D0'];
class Particle {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.vx = (Math.random() - 0.5) * (0.8 + level * 0.1) * SCALE;
this.vy = (Math.random() - 0.5) * (0.8 + level * 0.1) * SCALE;
this.size = (Math.random() * 8 + 6) * SCALE;
this.active = true;
const typeRand = Math.random();
if (typeRand < 0.10) {
this.isRare = true;
if (typeRand < 0.01) this.color = '#FF4500';
else if (typeRand < 0.025) this.color = '#FF69B4';
else this.color = '#E6E6FA';
} else {
this.isRare = false;
this.color = NORMAL_COLORS[Math.floor(Math.random() * NORMAL_COLORS.length)];
}
}
update() {
if (!this.active) return;
this.x += this.vx; this.y += this.vy;
if (this.x < 0 || this.x > width) this.vx *= -1;
if (this.y < 0 || this.y > height) this.vy *= -1;
}
draw() {
if (!this.active) return;
ctx.save();
ctx.globalAlpha = 0.8;
ctx.fillStyle = this.color;
if(this.isRare) {
ctx.shadowColor = this.color;
ctx.shadowBlur = (this.color === '#FF4500' ? 15 : 10) * SCALE;
}
ctx.beginPath();
ctx.arc(this.x, this.y, this.size/2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
explode() {
this.active = false;
playChainSound(chainCount, this.isRare);
let sc = 6;
if (this.isRare) sc = this.color === '#FF4500' ? 12 : 10;
for (let i = 0; i < sc; i++) {
shards.push(new Shard(this.x, this.y, this.color));
}
}
}
class Ripple {
constructor(x, y, isRare = false, color = '#FFFFFF') {
this.x = x; this.y = y;
this.radius = 0;
this.isRare = isRare;
this.color = color;
this.life = isRare ? 120 : 100;
this.expansionSpeed = 2.0 * SCALE;
this.active = true;
}
update() {
this.radius += this.expansionSpeed;
this.life -= 1.5;
if (this.life <= 0) this.active = false;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
const alpha = Math.max(0, this.life / (this.isRare ? 120 : 100));
if (this.isRare) {
let r = 255, g = 100, b = 150;
if (this.color === '#FF4500') { r = 255; g = 100; b = 100; }
else if (this.color === '#FF69B4') { r = 255; g = 150; b = 200; }
else if (this.color === '#E6E6FA') { r = 200; g = 180; b = 255; }
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
ctx.lineWidth = 3 * SCALE;
} else {
ctx.strokeStyle = `rgba(200, 240, 255, ${alpha})`;
ctx.lineWidth = 1.5 * SCALE;
}
ctx.stroke();
}
}
class Shard {
constructor(x, y, color) {
this.x = x; this.y = y; this.color = color;
this.size = (Math.random() * 3 + 1) * SCALE;
const angle = Math.random() * Math.PI * 2;
const speed = (Math.random() * 4 + 1) * SCALE;
this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed;
this.gravity = 0.05 * SCALE; this.life = 100; this.active = true;
}
update() {
this.x += this.vx; this.y += this.vy;
this.vy += this.gravity; this.life -= 2.0;
if (this.life <= 0) this.active = false;
}
draw() {
ctx.save();
ctx.fillStyle = this.color;
ctx.globalAlpha = Math.max(0, this.life / 100);
ctx.fillRect(this.x, this.y, this.size, this.size);
ctx.restore();
}
}
function initLevel() {
const dpr = window.devicePixelRatio || 1;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.scale(dpr, dpr);
const baseDiagonal = Math.sqrt(1280 * 720);
const currentDiagonal = Math.sqrt(width * height);
SCALE = Math.max(0.4, Math.min(currentDiagonal / baseDiagonal, 1.2));
const baseArea = 1280 * 720;
const currentArea = width * height;
currentMaxParticles = Math.max(40, Math.min(200, Math.floor(150 * (currentArea / baseArea))));
const ratio = Math.min(0.3 + (level * 0.05), 0.9);
targetCount = Math.floor(currentMaxParticles * ratio);
levelEl.innerText = level;
targetEl.innerText = `クリア目標: ${targetCount} / ${currentMaxParticles}`;
scoreEl.innerText = `現在の連鎖: 0`;
startMsg.style.display = 'block'; // メッセージを再表示
particles = [];
for (let i = 0; i < currentMaxParticles; i++) particles.push(new Particle());
ripples = []; shards = [];
chainCount = 0;
gameStarted = false; gameEnded = false;
nextBtn.style.display = 'none';
retryBtn.style.display = 'none';
}
function drawWaves() {
const time = Date.now() * 0.001;
ctx.fillStyle = 'rgba(0, 40, 80, 0.4)';
ctx.beginPath();
ctx.moveTo(0, height);
for(let x = 0; x <= width; x += 30 * SCALE) {
let y = height * 0.4 + Math.sin(x * 0.003 / SCALE + time) * 60 * SCALE + Math.sin(x * 0.01 / SCALE - time * 0.5) * 20 * SCALE;
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.fill();
ctx.fillStyle = 'rgba(0, 60, 100, 0.3)';
ctx.beginPath();
ctx.moveTo(0, height);
for(let x = 0; x <= width; x += 30 * SCALE) {
let y = height * 0.6 + Math.sin(x * 0.004 / SCALE - time * 0.8) * 40 * SCALE;
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.fill();
}
function animate() {
ctx.fillStyle = '#001a33';
ctx.fillRect(0, 0, width, height);
drawWaves();
particles.forEach(p => {
p.update(); p.draw();
ripples.forEach(r => {
const dx = p.x - r.x;
const dy = p.y - r.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const hitThickness = 20 * SCALE;
if (p.active && dist < r.radius + p.size && dist > r.radius - hitThickness) {
p.explode();
ripples.push(new Ripple(p.x, p.y, p.isRare, p.color));
chainCount++;
scoreEl.innerText = `現在の連鎖: ${chainCount}`;
}
});
});
ctx.globalCompositeOperation = 'lighter';
shards = shards.filter(s => s.active);
shards.forEach(s => { s.update(); s.draw(); });
ctx.globalCompositeOperation = 'source-over';
ripples = ripples.filter(r => r.active);
ripples.forEach(r => { r.update(); r.draw(); });
if (gameStarted && !gameEnded && ripples.length === 0 && shards.length === 0) {
gameEnded = true;
if (chainCount >= targetCount) {
nextBtn.style.display = 'block';
} else {
retryBtn.style.display = 'block';
}
}
requestAnimationFrame(animate);
}
// --- 強力なボタンイベント管理 ---
function handleNextLevel(e) {
e.preventDefault(); e.stopPropagation();
level++; initLevel();
}
function handleRetryLevel(e) {
e.preventDefault(); e.stopPropagation();
initLevel();
}
nextBtn.addEventListener('touchstart', handleNextLevel, { passive: false });
nextBtn.addEventListener('mousedown', handleNextLevel);
retryBtn.addEventListener('touchstart', handleRetryLevel, { passive: false });
retryBtn.addEventListener('mousedown', handleRetryLevel);
// --- キャンバスのタッチ管理 ---
function handleInput(e) {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
initAudio();
if (!gameStarted) {
startMsg.style.display = 'none'; // タップしたらメッセージを消す
let x = e.clientX;
let y = e.clientY;
if (e.touches && e.touches.length > 0) {
x = e.touches[0].clientX;
y = e.touches[0].clientY;
}
if (x !== undefined && y !== undefined) {
ripples.push(new Ripple(x, y, false, '#FFFFFF'));
gameStarted = true;
}
}
}
window.addEventListener('mousedown', handleInput, { passive: false });
window.addEventListener('touchstart', handleInput, { passive: false });
window.addEventListener('resize', () => {
setTimeout(initLevel, 200);
});
initLevel(); animate();
</script>
</body>
</html>


■ 今日の学び

…夢中になって、気づけば1時間ぐらい遊んでしまいました…
連鎖の音もとても癒されます。(* ̄o ̄*)~♪
レベル25ぐらいになるとシーグラスの動きが早くなり、クリアも簡単にはできなくなります。

ゲームを仕上げている途中、シーグラスが出なくなってしまう不具合が発生しました。
何度もAIと修正しても改善せず困っていたので、動かなくなる直前のコードをコピーして相談すると…

AI:「原因の切り分け、本当にありがとう!
動いていた確実なコード(v4.0)を教えてもらったおかげで、どこでつまずいていたのかハッキリわかったよ。

私が良かれと思って追加した“新しい透明度の計算”や“描画エンジンの軽量化処理”が、
そちらの環境(メモ帳での実行環境)と相性が悪くてフリーズを引き起こしていたみたいだ。ごめんね。」

AIも正確に原因をつかんでくれて、すぐにきちんとした仕上がりにしてくれました。
「伝え方を工夫することの大切さ」を感じた出来事でした。(>v<)b”

■ 次回は、小6ギャルの学校との対立

 次回の主人公として登場する小6ギャルは、
 以前 AI と一緒にサウンドノベルを作ろうとしたときに
 生まれたキャラクターです。

 そのときの物語は記事化していませんが、
 AI が考えてくれた設定がとても面白く、
 今回のゲームでも再登場してもらいました。

 ゲームの化学反応を楽しみながらAIと作ったゲームです。

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

次の実験はこちら(物理と物語のシューティング):「エクレアジター弾幕シューティング」



 ゲーム作りが楽しくて、どんどん作品が増えています(*^v^*)
 もし応援していただけたら、とても励みになります。

~このブログについて~

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

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


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

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

続きを読む