実験#78:膨らまして飛ばして割る(風船膨張・飛翔シミュレーター)

■78個目 「風船膨張・飛翔シミュレーター」

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

今回も、シミュレーションゲームを作ってみました。

テーマは、「風船ふくらませ」です!

実はこのゲーム、最初は「ゴムが伸びる限界のリアリティ」をとことん追求して作っていたんです。(`・ω・´)

空気を押し込むたびにゴムが薄く透けていき、
限界が近づくとブルブル震え出す……

「いつ割れるの!?」という、あのギリギリの緊張感を本気で再現しました。

でも、無事に膨らみきった風船を見ていたら、
「せっかくなら、風船たちが綺麗に浮かべる素敵な空間を作ってあげたいな」
と、思うようになりまして。(=v=*)

そこで思いついたのが、キラキラ輝く “ハーバリウム” の小瓶の中です!

パステルカラーのオイルの中を、花びらや気泡と一緒に、
自分が限界ギリギリまで膨らませた風船がゆったりと舞い上がっていく……。

スリル満点な前半から一転、
ふわりと癒やされる優しい空間に仕上がりました。(´▽`*)

そっと触れてあげると、 ポロロン♪ と
小さな音を立てながら嬉しそうに揺れるんです。(´ω`*)

そして、ここからがこのゲームの隠し味!

たくさん浮かべた風船たちを、

最後にキーボードの 「Fキー」 を押しながらタッチしてみてください。

「パーーンッ!!」という小気味よい音とともに、
まるで花火のように弾け散ります。(≧▽≦)

静かなハーバリウムの世界で風船を次々と割っていく、
この不思議な “爽快感”。 ちょっとしたストレス発散に最高なんです(笑)

風船が割れるドキドキと、ハーバリウムのほっこり感、
そして最後のスカッと感。

毎日がんばっている皆さんに、
いろんな感情のデトックスをお届けできたら嬉しいです。

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

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

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

風船を膨らます、膨張と限界点での破裂をシミュレートしたいです。
ゴムの伸縮のリアリティを最大限に再現できるかな?

AI: いらっしゃい!今日もゲーム作りの修行かい?
「ただ大きくなるんじゃなく、ゴムが伸びていくリアリティ」……

いいねぇ。 今回は CSS の変数を使って、
色の薄れ と 形状変化 を段階的に再現してみたよ。

限界点はランダムだから、スリルも味わえるはずだ。

みー:(*^v^)いいね。
8割以上膨らませたら「飛ばすボタン」で上空へ飛んでいくようにしよう。

飛んだ風船は画面上部にたまっていく感じで。
色もランダムにしたいです。

AI: 了解!
画面を縦長にして、下が作業場、上が空になるレイアウトにしたよ。

飛ぶときは フワフワ揺れる浮遊感 も追加しておいた。

みー:(^w^)
飛ばした瞬間に消えるんじゃなくて、スーッと上昇する感じにしたい。
あと、マウスで触って動かせるようにしたいね。

Fキーを押しながら触れると割れる とかもできる?

AI: なるほど、見えない針で風船と遊ぶ感じだな。 もちろんできるぜ。

みー:(=v=) 画面の端に「自動風船ボタン」も作ろう。
Jキーを押しながら触れると、
飛んでる風船をやさしくなでられる感じにしたい。

AI: あんた、すっかり風船を愛でる達人だな。
空いっぱいの風船を一つずつなでる……
詩的でいいじゃないか。

みー:ヾ(*^v^*〳
背景が黒くて地味なので、ハーバリウムみたいに明るくできる?

AI: 任せな。
ガラス瓶の中に光が透ける、あの柔らかい世界観だな。

ちなみに「ハーバリウム」って本来は植物標本のことなんだぜ。
それが今では癒やしアイテムに進化してるんだから、面白いよな。

■ 実際の動作

AIが最初に出してくれたゲームの画面です。

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

こちらが今回の 仕上がりの画面 です。
「空気を押し込む」ボタンを押すたびに、風船が少しずつ膨らんでいきます。
手を放して「放つ」ボタンを押すと、ふわりと浮かび上がります。

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


ギリギリ限界を超えて空気を入れると、
「パーンッ!」と破裂してしまい、やり直しになります。

(画像:限界超えの破裂)



自分で膨らませることもできますが、
画面の端には 自動製造ボタン があり、
風船をどんどん作ってくれる便利機能もあります。

(画像:自動製造機能)

画面をスクロールすると、
作った風船たちが上の方でぷかぷか浮かんでいます。

(画像:作った風船たち)

Jキー を押しながらマウスを近づけると、 風船に触れられて 心地よい音 が鳴ります。🎈

Fキー を押しながら触れると、 風船はその瞬間に パーンッ! と破裂していきます。

(画像:Fキーで一気に破裂)


■ コードのポイント

今回の記事では、AI と相談しながら作った
「風船膨張・飛翔シミュレーター」 の HTML コードを公開します。

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

ブラウザだけで動く、とてもシンプルなゲームなので、
気軽に試してみてくださいね。

▼ 遊ぶまでの手順

①PC の「メモ帳」(※Windowsの場合)や  
 Mac の「テキストエディット」など、テキストエディタを開きます。

②下のコードをコピーして貼り付けます。

③「fuusen.html」など、好きな名前で保存します。
 (※拡張子は .html にしてください)

④保存したファイルをブラウザで開けば、すぐに遊べます。

 

■ 操作方法

操作はとっても簡単!マウスとキーボードを使って、
風船とのふれあいを楽しんでくださいね。(^_^)

※ぜひ画面右上の「サウンド」をONにして遊んでみてください♪

🎈 1. 風船をふくらまそう!
画面下にある「空気を押し込む」ボタンを
ポチポチ押して、風船をふくらませます。

空気が入るにつれてゴムが透けていき、
限界が近づくとブルブル震え出します……!

欲張りすぎると「パーーンッ!」と割れてしまうので、
ギリギリを見極めてくださいね。(>_<)

🌬️ 2. ハーバリウムの空へ放とう!
風船が8割以上ふくらむと、
「手を離して放つ」ボタンがキラキラ光ります✨
このボタンを押すと、風船がパステルカラーのオイルの中へ
ふわりと浮かび上がります。(´▽`*)
上にスクロールして、浮かんでいく様子を追いかけてみてくださいね。

👆 3. 優しくなでなでしてみよう(Jキー + マウス)
浮かんだ風船にそのままマウスカーソルを近づけると、
ツンッと弾かれて逃げてしまいます。
でも、キーボードの【 Jキー 】を押しながらマウスで優しく触れると……?
「ポロロン♪」という音と一緒に、
風船が嬉しそうに揺れてくれます。(´ω`)

💥 4. スカッと爽快!お片付け魔法(Fキー + マウス)
画面が風船でいっぱいになった時や、
ちょっとストレス発散したい時はコレ!
キーボードの【 Fキー 】を押しながら風船に触れると、
花火のようにパッと弾けて消えちゃいます。
静かな空間で次々と割っていくこの謎の爽快感、
ぜひ体験してみてほしいです。(≧▽≦)

🤖 5. のんびり眺める「自動製造機」
画面右下の「自動製造機」ボタンをONにすると、
自動で風船をふくらませて次々と空へ放ってくれます。
あたたかい飲み物でも飲みながら、
ボトルの中がカラフルな風船でいっぱいになっていくのを
ボ〜ッと眺めるのもオススメですよ。(=v=*)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>風船膨張・飛翔シミュレーター(ハーバリウム空間版)</title>
<style>
:root {
--bg-color: #fdfbfb;
--text-color: #5d4037; /* 明るい背景に合う上品なブラウン */
}
html, body {
margin: 0; padding: 0;
font-family: 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-color); color: var(--text-color);
scroll-behavior: smooth; overflow-x: hidden;
}
body { height: 200vh; position: relative; }
/* ハーバリウム風の背景(オイルの透明感とパステルグラデーション) */
#herbarium-bg {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
z-index: -2;
}
/* 光のボカシ(瓶の中の反射を表現) */
#herbarium-glow {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background:
radial-gradient(circle at 20% 30%, rgba(255, 182, 193, 0.4) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(144, 238, 144, 0.3) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, rgba(255, 253, 208, 0.5) 0%, transparent 60%);
filter: blur(20px);
z-index: -1;
}
/* 漂う花びらや気泡 */
.petal, .bubble-bg {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: -1;
}
.petal { background: rgba(255, 192, 203, 0.6); filter: blur(2px); }
.bubble-bg { border: 1px solid rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.2); }
/* 地上エリア(ガラスのような磨りガラス効果:グラスモーフィズム) */
#ground {
position: absolute; top: 100vh; width: 100vw; height: 100vh;
display: flex; flex-direction: column; align-items: center; justify-content: center;
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255,255,255,0.8);
z-index: 10;
}
body.flash #ground { background: #fff0f5; } /* 割れた時のフラッシュも優しく */
body.shake-screen { animation: screenShake 0.4s cubic-bezier(.36,.07,.19,.97) both; }
@keyframes screenShake {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-8px, 4px); }
40% { transform: translate(8px, -4px); }
60% { transform: translate(-4px, 8px); }
80% { transform: translate(4px, -8px); }
}
#sound-toggle {
position: absolute; top: 20px; right: 20px;
padding: 10px 20px; font-size: 1rem; cursor: pointer;
background: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.8);
color: var(--text-color); border-radius: 20px; z-index: 100;
backdrop-filter: blur(5px); transition: 0.2s;
}
#sound-toggle.on { background: rgba(144, 238, 144, 0.5); border-color: #81c784; }
#auto-btn {
position: fixed; bottom: 30px; right: 30px;
padding: 15px 25px; font-size: 1.1rem; cursor: pointer;
background: linear-gradient(to bottom, #ff9a9e 0%, #fecfef 99%, #fecfef 100%);
border: none; color: #5d4037; font-weight: bold; border-radius: 30px;
box-shadow: 0 6px 0 #ff758c, 0 5px 15px rgba(255, 117, 140, 0.3);
z-index: 1000; transition: 0.1s; outline: none;
}
#auto-btn:active { transform: translateY(6px); box-shadow: 0 0 0 #ff758c; }
#auto-btn.on {
background: linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%);
box-shadow: 0 6px 0 #8e7bb0, 0 0 20px rgba(251, 194, 235, 0.8);
animation: pulse 2s infinite; color: white;
}
@keyframes pulse { 0% { transform: scale(1); } 50% { transform: scale(1.03); } 100% { transform: scale(1); } }
#game-area {
position: relative; width: 300px; height: 400px;
display: flex; align-items: flex-end; justify-content: center; margin-bottom: 20px;
}
/* 風船(明るい背景に合わせて影を微調整) */
.balloon {
position: absolute; --hue: 350; --size: 50px; --roundness-bottom: 65%; --lightness: 50%; --alpha: 1;
width: var(--size); height: calc(var(--size) * 1.15);
border-radius: 50% 50% var(--roundness-bottom) var(--roundness-bottom) / 50% 50% 50% 50%;
background: radial-gradient(circle at 35% 30%, hsla(var(--hue), 100%, calc(var(--lightness) + 30%), var(--alpha)), hsla(var(--hue), 100%, var(--lightness), var(--alpha)));
box-shadow: inset -10px -10px 20px rgba(0,0,0,calc(var(--alpha) * 0.2)), 0 15px 25px rgba(0,0,0,calc(var(--alpha) * 0.15));
transform-origin: bottom center;
transition: width 0.1s ease, height 0.1s ease, background 0.2s, box-shadow 0.2s;
z-index: 20;
}
.balloon::after {
content: ''; position: absolute; bottom: -8px; left: 50%; transform: translateX(-50%);
width: 10px; height: 10px; background: hsla(var(--hue), 100%, var(--lightness), 1); border-radius: 2px;
}
.flown { top: 0; left: 0; cursor: grab; will-change: transform; }
.flown:active { cursor: grabbing; }
.pump-action { animation: pumpAction 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
@keyframes pumpAction { 0% { transform: scale(1); } 50% { transform: scale(1.02) translateY(-2px); } 100% { transform: scale(1); } }
.danger-tremble { animation: tremble 0.1s infinite; }
@keyframes tremble { 0% { transform: translate(0, 0); } 25% { transform: translate(1px, 1px); } 50% { transform: translate(-1px, -1px); } 75% { transform: translate(-1px, 1px); } 100% { transform: translate(1px, -1px); } }
.shard { position: absolute; background: hsla(var(--hue), 100%, 60%, 0.7); transform-origin: center; z-index: 30; }
#controls { display: flex; gap: 15px; margin-bottom: 20px; }
button.action-btn {
padding: 15px 30px; font-size: 1.1rem; cursor: pointer; border: none; border-radius: 30px;
color: #5d4037; font-weight: bold; transition: 0.2s; outline: none; backdrop-filter: blur(5px);
}
#pump-btn { background: rgba(255, 228, 225, 0.8); box-shadow: 0 6px 0 #e0baba; border: 1px solid #fff; }
#pump-btn:active { transform: translateY(6px); box-shadow: 0 0 0 #e0baba; }
#fly-btn { background: rgba(224, 255, 255, 0.8); box-shadow: 0 6px 0 #b2d8d8; border: 1px solid #fff; opacity: 0.5; cursor: not-allowed; }
#fly-btn.ready { opacity: 1; cursor: pointer; animation: glow-pastel 1.5s infinite alternate; }
#fly-btn.ready:active { transform: translateY(6px); box-shadow: 0 0 0 #b2d8d8; }
@keyframes glow-pastel { from { box-shadow: 0 6px 0 #b2d8d8, 0 0 10px rgba(224, 255, 255, 0.5); } to { box-shadow: 0 6px 0 #b2d8d8, 0 0 20px rgba(175, 238, 238, 1); } }
#message { font-size: 1.1rem; min-height: 2em; letter-spacing: 1px; text-align: center; line-height: 1.4; font-weight: 500; }
#scroll-hint { position: absolute; top: 20px; color: #888; font-size: 0.9rem; animation: bounce 2.5s infinite; pointer-events: none; text-align: center; line-height: 1.5; }
@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-8px); } 60% { transform: translateY(-4px); } }
</style>
</head>
<body>
<div id="herbarium-bg"></div>
<div id="herbarium-glow"></div>
<button id="auto-btn">自動製造機: OFF</button>
<div id="ground">
<button id="sound-toggle">サウンド: OFF</button>
<div id="scroll-hint">↑ スクロールで空のボトルを確認 ↑<br>(F+タッチで破裂 / J+タッチで優しくなでる)</div>
<div id="game-area"><div id="balloon" class="balloon"></div></div>
<div id="controls">
<button id="pump-btn" class="action-btn">空気を押し込む</button>
<button id="fly-btn" class="action-btn">手を離して放つ</button>
</div>
<div id="message"></div>
</div>
<script>
// --- ハーバリウム背景のパーティクル生成 ---
function createHerbariumEffects() {
const bg = document.getElementById('herbarium-bg');
// 花びらと気泡を生成
for(let i=0; i<30; i++) {
let el = document.createElement('div');
let isBubble = Math.random() > 0.5;
el.className = isBubble ? 'bubble-bg' : 'petal';
let size = isBubble ? 5 + Math.random()*15 : 10 + Math.random()*20;
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.left = `${Math.random()*100}vw`;
// 下から上へゆっくり漂うアニメーション
let duration = 20000 + Math.random()*30000; // 20〜50秒
let delay = -Math.random()*30000; // すでに画面内にいるようにマイナス遅延
el.animate([
{ transform: `translateY(200vh) rotate(0deg)`, opacity: 0 },
{ opacity: isBubble ? 0.8 : 0.6, offset: 0.1 },
{ opacity: isBubble ? 0.8 : 0.6, offset: 0.9 },
{ transform: `translateY(-20vh) rotate(${Math.random()*360}deg)`, opacity: 0 }
], {
duration: duration,
delay: delay,
iterations: Infinity,
easing: 'linear'
});
bg.appendChild(el);
}
}
createHerbariumEffects();
// --- 音響システム(柔らかめに調整) ---
let audioCtx;
let isSoundOn = false;
let bgmInterval;
const bgmNotes = [261.63, 329.63, 392.00, 523.25];
let noteIndex = 0;
function initAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function toggleSound() {
initAudio();
isSoundOn = !isSoundOn;
const btn = document.getElementById('sound-toggle');
if (isSoundOn) {
btn.innerText = 'サウンド: ON'; btn.classList.add('on');
if(audioCtx.state === 'suspended') audioCtx.resume();
startBGM();
} else {
btn.innerText = 'サウンド: OFF'; btn.classList.remove('on');
stopBGM();
}
}
document.getElementById('sound-toggle').addEventListener('click', toggleSound);
function playTone(freq, type, duration, vol, slideToFreq = null) {
if (!isSoundOn || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// ハーバリウムの雰囲気に合わせて少し丸い音に(sineを多用)
osc.type = type === 'square' ? 'sine' : type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
if (slideToFreq) osc.frequency.exponentialRampToValueAtTime(slideToFreq, audioCtx.currentTime + duration);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
osc.connect(gain); gain.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + duration);
}
function playNoise(duration, vol) {
if (!isSoundOn || !audioCtx) return;
const bufferSize = audioCtx.sampleRate * duration;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = audioCtx.createBufferSource(); noise.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration);
// ローパスフィルターでノイズを少し柔らかく(水中の破裂音風に)
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.value = 1000;
noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination);
noise.start();
}
function playPumpSE() { playTone(200, 'sine', 0.2, 0.05, 300); }
function playPopSE() { playNoise(0.6, 0.4); playTone(150, 'triangle', 0.4, 0.2, 50); }
function playFlySE() { playTone(500, 'sine', 1.5, 0.05, 1000); }
function playBounceSE() { playTone(700, 'sine', 0.3, 0.05, 400); }
function playPetSE() {
if(!isSoundOn) return;
const baseFreq = 500 + Math.random() * 200;
playTone(baseFreq, 'sine', 0.4, 0.04);
playTone(baseFreq * 1.25, 'sine', 0.4, 0.02);
}
function startBGM() {
if (bgmInterval) clearInterval(bgmInterval);
bgmInterval = setInterval(() => {
if (!isSoundOn) return;
playTone(bgmNotes[noteIndex], 'sine', 0.2, 0.02); // BGMもオルゴール風に
noteIndex = (noteIndex + 1) % bgmNotes.length;
}, 300); // テンポもゆったり
}
function stopBGM() { if (bgmInterval) clearInterval(bgmInterval); }
// --- ゲームのグローバル状態 ---
const balloon = document.getElementById('balloon');
const pumpBtn = document.getElementById('pump-btn');
const flyBtn = document.getElementById('fly-btn');
const autoBtn = document.getElementById('auto-btn');
const msg = document.getElementById('message');
let size = 50; let limit = 0; let currentHue = 0;
let isPopped = false; let isFKeyPressed = false; let isJKeyPressed = false;
let isAutoOn = false; let autoTimer = null;
// --- 空間物理演算(オイルの粘度を表現) ---
let flownBalloons = [];
let mouseX = -1000;
let mouseY = -1000;
window.addEventListener('mousemove', (e) => { mouseX = e.pageX; mouseY = e.pageY; });
window.addEventListener('keydown', (e) => {
if(e.key.toLowerCase() === 'f') isFKeyPressed = true;
if(e.key.toLowerCase() === 'j') isJKeyPressed = true;
});
window.addEventListener('keyup', (e) => {
if(e.key.toLowerCase() === 'f') isFKeyPressed = false;
if(e.key.toLowerCase() === 'j') isJKeyPressed = false;
});
function init() {
size = 50; isPopped = false; limit = Math.floor(Math.random() * 120) + 180;
currentHue = Math.floor(Math.random() * 360);
balloon.style.setProperty('--hue', currentHue);
balloon.style.display = 'block'; balloon.classList.remove('danger-tremble');
document.body.classList.remove('flash', 'shake-screen');
updateBalloon();
if(!isAutoOn) {
msg.innerHTML = '明るくていい雰囲気だろう。<br>音をONにして、癒やしの時間を楽しんでな。';
window.scrollTo(0, document.body.scrollHeight);
}
pumpBtn.innerText = '空気を押し込む';
}
function updateBalloon() {
const progress = Math.min((size - 50) / (limit - 50), 1);
const lightness = 50 + (progress * 15); const alpha = 1 - (progress * 0.4);
const roundnessBottom = Math.max(50, 65 - (progress * 15));
balloon.style.setProperty('--size', `${size}px`); balloon.style.setProperty('--lightness', `${lightness}%`);
balloon.style.setProperty('--alpha', `${alpha}`); balloon.style.setProperty('--roundness-bottom', `${roundnessBottom}%`);
if(progress >= 0.8) flyBtn.classList.add('ready');
else flyBtn.classList.remove('ready');
if(progress > 0.85 && !balloon.classList.contains('danger-tremble')) balloon.classList.add('danger-tremble');
}
function pop() {
isPopped = true; balloon.style.display = 'none'; flyBtn.classList.remove('ready');
document.body.classList.add('flash', 'shake-screen');
setTimeout(() => { document.body.classList.remove('flash'); }, 50);
playPopSE();
msg.innerHTML = '<span style="color:#ff6b6b; font-weight:bold; font-size:1.5em;">パーーンッ!</span><br>儚いもんだな。次いこう。';
pumpBtn.innerText = '新しい風船を取り出す';
const rect = balloon.getBoundingClientRect();
const startX = rect.left + window.scrollX + (rect.width / 2); const startY = rect.top + window.scrollY + (rect.height / 2);
for(let i = 0; i < 35; i++) createGlobalShard(startX, startY, currentHue, size);
}
function fly() {
const progress = (size - 50) / (limit - 50); if(progress < 0.8) return;
playFlySE();
const rect = balloon.getBoundingClientRect();
const startX = rect.left + window.scrollX;
const startY = rect.top + window.scrollY;
const targetX = Math.random() * (window.innerWidth - size - 20) + 10;
const targetY = Math.random() * (window.innerHeight * 0.7);
const flyer = document.createElement('div'); flyer.classList.add('balloon', 'flown');
flyer.style.setProperty('--hue', currentHue); flyer.style.setProperty('--size', `${size}px`);
flyer.style.setProperty('--lightness', balloon.style.getPropertyValue('--lightness'));
flyer.style.setProperty('--alpha', balloon.style.getPropertyValue('--alpha'));
flyer.style.setProperty('--roundness-bottom', '50%');
document.body.appendChild(flyer);
const balloonObj = {
el: flyer, x: startX, y: startY,
vx: (targetX - startX) * 0.01, // 勢いもオイルっぽくマイルドに
vy: -8 - Math.random() * 4,
targetX: targetX, targetY: targetY, size: size, hue: currentHue,
swayPhase: Math.random() * Math.PI * 2, lastPetTime: 0, popped: false
};
flownBalloons.push(balloonObj);
if(!isAutoOn) msg.innerHTML = '綺麗に浮かんでいったな。<br>Jキーで優しくなでてみな。';
init();
}
function physicsLoop() {
requestAnimationFrame(physicsLoop);
const now = Date.now();
flownBalloons.forEach(b => {
if (b.popped) return;
const cx = b.x + (b.size / 2);
const cy = b.y + (b.size * 1.15 / 2);
const dx = cx - mouseX;
const dy = cy - mouseY;
const dist = Math.sqrt(dx * dx + dy * dy);
const hitRadius = b.size * 1.1; // オイルの波及効果で少し遠くからでも反応する
let scaleFactor = 1;
if (dist < hitRadius) {
if (isFKeyPressed) { popFlownBalloon(b); return; }
if (isJKeyPressed) {
// なでる:非常に滑らかな力(オイルの中をかき分けるように)
b.vx += (dx / dist) * 0.15;
b.vy += (dy / dist) * 0.15 - 0.1;
scaleFactor = 1.03;
if (now - b.lastPetTime > 400) { playPetSE(); b.lastPetTime = now; }
} else {
// つつぐ:少し強めだが、空気中ほどは飛ばない
b.vx += (dx / dist) * 1.2;
b.vy += (dy / dist) * 1.2;
scaleFactor = 0.95;
if (now - b.lastPetTime > 300) { playBounceSE(); b.lastPetTime = now; }
}
}
// 【重力/浮力】ドロッとしたオイルの中を昇るような緩やかさ
if (b.y > b.targetY) {
b.vy -= 0.05;
} else {
b.vy += (b.targetY - b.y) * 0.001;
}
// 横移動
b.vx += (b.targetX - b.x) * 0.0002;
// Sway(揺らぎ)もゆっくりに
b.swayPhase += 0.015;
b.vx += Math.sin(b.swayPhase) * 0.08;
b.x += b.vx;
b.y += b.vy;
// 【重要】空気抵抗を「オイルの摩擦(粘度)」に変更。急激にブレーキがかかる。
b.vx *= 0.88;
b.vy *= 0.88;
if (b.x < 0) { b.x = 0; b.vx *= -0.5; }
if (b.x > window.innerWidth - b.size) { b.x = window.innerWidth - b.size; b.vx *= -0.5; }
b.el.style.transform = `translate3d(${b.x}px, ${b.y}px, 0) scale(${scaleFactor}) rotate(${b.vx * 3}deg)`;
});
flownBalloons = flownBalloons.filter(b => !b.popped);
}
physicsLoop();
function popFlownBalloon(b) {
b.popped = true; b.el.remove(); playPopSE();
const cx = b.x + (b.size / 2); const cy = b.y + (b.size / 2);
for(let i = 0; i < 30; i++) createGlobalShard(cx, cy, b.hue, b.size);
}
function createGlobalShard(startX, startY, hue, bSize) {
const p = document.createElement('div'); p.classList.add('shard'); p.style.setProperty('--hue', hue);
const w = 10 + Math.random() * 20; const h = 10 + Math.random() * 20;
p.style.width = `${w}px`; p.style.height = `${h}px`;
const p1 = `${Math.random()*20}% ${Math.random()*20}%`; const p2 = `${80 + Math.random()*20}% ${Math.random()*20}%`;
const p3 = `${50 + Math.random()*50}% ${80 + Math.random()*20}%`; const p4 = `${Math.random()*30}% ${80 + Math.random()*20}%`;
p.style.clipPath = `polygon(${p1}, ${p2}, ${p3}, ${p4})`;
p.style.left = `${startX - w/2}px`; p.style.top = `${startY - h/2}px`; document.body.appendChild(p);
// オイルの中での破裂なので飛び散り方も少しマイルドに
const angle = Math.random() * Math.PI * 2; const velocity = (30 + Math.random() * 80) * (bSize / 200);
const tx = Math.cos(angle) * velocity; const ty = Math.sin(angle) * velocity - 50; const rot = Math.random() * 360 - 180;
const anim = p.animate([
{ transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 },
{ transform: `translate(${tx}px, ${ty}px) rotate(${rot}deg) scale(0.5)`, opacity: 0 }
], { duration: 800 + Math.random() * 600, easing: 'cubic-bezier(0.1, 0.8, 0.3, 1)', fill: 'forwards' });
anim.onfinish = () => p.remove();
}
function toggleAuto() {
isAutoOn = !isAutoOn;
if(isAutoOn) {
autoBtn.innerText = '自動製造機: ON'; autoBtn.classList.add('on');
msg.innerHTML = '自動製造スタート!<br>ボトルの中が綺麗に染まっていくぜ。';
runAuto();
} else {
autoBtn.innerText = '自動製造機: OFF'; autoBtn.classList.remove('on');
msg.innerHTML = '自動製造ストップ。<br>ゆっくり自分のペースでどうぞ。';
clearTimeout(autoTimer);
}
}
autoBtn.addEventListener('click', toggleAuto);
function runAuto() {
if(!isAutoOn) return;
if(isPopped || balloon.style.display === 'none') { init(); autoTimer = setTimeout(runAuto, 1000); return; }
const progress = (size - 50) / (limit - 50);
const targetProgress = 0.85 + (Math.random() * 0.1);
if(progress >= targetProgress) {
fly(); autoTimer = setTimeout(runAuto, 1500 + Math.random() * 1000);
} else {
pumpBtn.click(); autoTimer = setTimeout(runAuto, 300 + Math.random() * 150);
}
}
pumpBtn.addEventListener('click', () => {
if(isPopped) { init(); return; }
if(!isAutoOn) playPumpSE();
const progress = Math.min((size - 50) / (limit - 50), 1);
const baseIncrement = 15; const resistance = progress * 10;
const increment = Math.max(2, baseIncrement - resistance + (Math.random() * 3));
size += increment;
balloon.classList.remove('pump-action'); void balloon.offsetWidth; balloon.classList.add('pump-action');
if(size > limit) { pop(); }
else {
updateBalloon();
if(!isAutoOn) {
if(progress > 0.9) { msg.innerHTML = 'ギリィッ…!<br>おい、もう透けてる!放つなら今だ!'; msg.style.color = '#ff6b6b'; }
else if(progress > 0.6) { msg.innerHTML = 'ギチギチ鳴り出したな。<br>空気を押し込むのに力がいってきただろう?'; msg.style.color = 'var(--text-color)'; }
}
}
});
flyBtn.addEventListener('click', () => { if(!isPopped) fly(); });
init();
</script>
</body>
</html>


■ 今日の学び

今回は、前回の「#77 緑の集合体ゲーム」に引き続き、
「画面の中のモノに直接触れられる感覚」 を再現したくて作ったゲームでした。

最初はとにかく風船の“リアリティ”だけを追い求めて、
ゴムの張りや動きの調整に没頭していました。

動き自体はすごくいい感じになったのですが……

ふと全体を引いて見た時に、

「あれ? 背景が真っ黒でなんだか暗くてパッとしないな ( ꒪⌓꒪)」

と気づいてしまったんです。

そこで最後の仕上げとして、思い切って
「ハーバリウム」という世界観 を取り入れてみました。

すると、風船の動きは全く同じなのに、
見違えるように素敵で明るい空間に生まれ変わったんです!✨

ただ、その瞬間にふと、

「前回の緑の集合体も、もっと素敵な背景にしてあげればよかった……!」

という後悔も押し寄せてきました(笑)。

毎日1個、完璧ではなくてもとにかく完成させていく
“不完全なゲームたち” ですが、
こうして「ここをこうすればよかった」という
反省を積み重ねていくことこそ、 何よりの学びだと実感しています。

この気づきを活かして、
これからのゲームをもっと良いものにしていきたいですし、
今まで作ってきた過去のゲームたちも、
いつか必ずパワーアップさせてあげたいと密かに燃えています。o(^v^)o”

これからも、どんどん進化していく実験室を
温かく見守っていただけると嬉しいです!

そしてこちらが、
今回の背景イメージの元になった 家に飾ってあるハーバリウム です。(^v^)

(画像:手作りハーバリウム)

■ 次回は、ちょっぴり不思議で平和的な海戦ゲームです。

基本は「相手の位置を探りながら攻撃していく」海戦ゲームなのですが、

ちょびっと魔法のファンタジーな世界観のゲームにしてみました。

次回もぜひ遊びに来てくださいね。(*^v^)ノシ

▶ 次の実験はこちら  
  (ファンタジー世界の対戦競技)
   「魔法生物海戦ゲーム」



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