■121個目 「丸太郎のソーダグラス大脱出」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、木もれ日が差し込むカフェのテラス席を舞台にした、
シュワシュワと爽やかなアクションゲームを作ってみました。
主役は、赤いハチマキをキリッと巻いた元気いっぱいの丸──
その名も「丸太郎」です!
おしゃれなウッド調の丸テーブルの上には、よく冷えたソーダグラス。
丸太郎は、グラスの底から絶え間なく湧き上がる泡を踏み台にしながら、
ふちを飛び越えて「大脱出」を目指します。
今回こだわったのは、「泡のリアルな動きと弾ける音」です。(=v=*)
冷たい炭酸水を見つめているときの、
あの心地よいシュワシュワ感を ゲームで触れたらどうなるんだろう──
そんな遊び心から世界観が生まれました。
気泡たちは水面にたどり着くと、
ぷっくり半円に膨らんで一瞬とどまり、
パツンッ!と弾けて細かな水しぶきを上げます。
大きな泡は少し低め、小さな泡は高めの音で「シュワッ」と弾けるので、
耳をすませば本当に冷たいソーダを目の前にしているような気分になれますよ。
ただフワフワ浮くだけでは脱出できません。
泡の力をしっかりチャージして、
丸太郎の気合いがマックスになった瞬間だけの
大ジャンプが成功の鍵になります。
緑あふれるテラスの風と、爽やかなソーダの弾ける感覚。
ぜひ丸太郎と一緒に味わってみてくださいね!(*^v^)ノシ
■ AIとのやりとりで面白かった部分
● ゲーム仕様の相談からスタート
みー:(^v^*)今日もゲームを作ります。HTMLで動かせるゲームです。
炭酸のシュワシュワみたいに、次々と浮かんで消えていく無数の泡。
その上を歩ける、アクションゲームを作りたいです。
AI: 炭酸といえば……グラス1杯のシャンパンの中に、
どれくらいの泡が生まれては消えていくか知ってるかい?
実は約500万個。
ビールの約100万個と比べると、その細かさがよくわかるよね。
さて、今日はそんな「シュワシュワの泡」をイメージした
アクションゲームを作ってみたよ。
浮かんでくる泡をタイミングよく踏み台にして、
落ちないように跳ね続けるゲームだ。
シンプルだけど、テンポ良くパツン!
と弾ける感覚が癖になるように調整してある。
みー:(^_^) もっと気泡は細かく、
上まで上がらず4分の3くらいの位置で消える感じにできますか?
AI: 細かい気泡が途中でふっと消える……いいね、
よりグラスの中の炭酸らしくなってきた。
ところで「シュワシュワ音」って、ただ空気が弾けているだけじゃないんだ。
気泡が水面で割れるとき、
残されたくぼみが振動して音を出す「ミナルト共鳴」という現象が起きる。
小さな泡ほど高い音になるから、
あの涼しげな音は小さな泡たちのオーケストラなんだよ。
リクエストに合わせてコードを書き直したよ。
気泡のサイズを小さくして数を増やし、
画面の上から4分の1の位置に見えない水面を作った。
そこに到達した泡は自然にパツンと弾けて消えるようにしてある。
みー:(=v=) 途中から発生する泡も混ぜて、
近い距離の泡は合体して大きくなるようにできますか?
大きさによって上昇スピードも変わる感じで。
AI: 途中からポコッと生まれる泡、近づくと合体して大きくなる泡、
そして大きさに比例した浮力……
うん、より「生きた炭酸」っぽくなりそうだ。
大きくなった泡は浮力を増してスッと速く上昇するようにしてあるよ。
みー:( ̄v ̄) なんだか右の超音波振動機みたいに震えすぎて、
自然な気泡感がなくなってます。
AI: あぁ、ほんとだ……!
計算のテンポが速すぎて泡たちがパニックを起こしてたみたいだ。
ゆったりしたサイン波で自然に昇るように修正したよ。
ところで、宇宙空間で炭酸飲料を飲むとどうなるか知ってるかい?
無重力だと液体と泡が全部混ざり合ってしまうから、
ゲップをすると液体ごと戻ってきちゃうんだ。
「宇宙用コーラ」の研究もあったけど、なかなか難しい問題らしいよ。
みー:(^v^) 水面まで上がった泡を、
サイズに合った半円泡にして一瞬とどまり、弾けさせたいです。
AI: いいねぇ! 水面にぷつぷつと半円の泡が浮かんでは弾ける感じ。 リアリティが一気に増すね。
炭酸水の歴史も面白くてね。
1767年、科学者プリーストリがビール醸造所で偶然作ったのが始まりなんだ。
発酵で出た二酸化炭素が水に溶け込んで、爽やかな炭酸水ができたんだよ。
リクエスト通り、水面で半円状に膨らんでパツンと弾ける演出を追加したよ。
みー:(^v^) 丸が泡に当たった時、小さな泡に分離して飛び散り、
他の泡に吸収されて大きくなるようにできますか?
丸にはメーターを付けて、
落下中に一定数の泡に触れないとバウンドしないようにしましょう。
AI: 泡が弾けて微小泡になり、それが他の泡に吸収されて成長する……
そしてエネルギーを充填しないと跳ね返れないメーター制。
一気に本格的な駆け引きが生まれるね。
ちなみに、シャンパンのコルクを抜いたときの白い煙は、
炭酸ガスじゃなくて「断熱膨張」で空気中の水蒸気が凍った霧なんだよ。
理科の実験みたいでロマンがあるよね。
● メーターシステムの導入
- プレイヤーの頭上にゲージが追加
- 落下中に泡に触れるとメーターが溜まる(必要数:3 → 後に5へ)
- メーター満タンの時だけ大バウンド
- バウンド中は泡をすり抜ける
● 泡の分解と吸収
- 触れた泡は超微小泡に分解
- 飛散した泡はサイズに応じた浮力でゆっくり上昇
- 他の泡に触れると吸収されて成長
みー:(^v^) 水面に出た時の弾ける表現をもっとリアルに。
音もほしいです。
AI: 視覚と音の両方でリアリティを出してみたよ。
- 半円状の水面ドームがプルッと揺れる
- 水しぶきが「ピシュッ」と飛び散る
- 波紋が広がる
- 泡のサイズに応じた「シュワッ」という効果音をリアルタイム生成
炭酸の高音は「より冷たく感じる」という心理効果もあるんだよ。
みー:(*^o^) ゲーム画面をジュースグラスみたいにしましょう。
ふちから飛び出せたらクリアで次のステージへ。
AI: グラスの形状とステージ進行、すごくいいね!
細長いグラスが炭酸を長持ちさせる理由も面白くてね。
液体が空気に触れる表面積を小さくするためなんだ。
● ジュースグラス型ステージ
- 透明感のあるジュースグラスに
- ステージごとにジュースの色が変化
- ふちを越えればステージクリア
- ファンファーレとともに次のステージへ
みー:(^v^) 放置しててもクリアできる難易度ですね。
メーターとふちの距離を調整して、
丸を“わんぱく丸太郎”にしましょう。
AI: よし、歯ごたえのある難易度に締め直して、主役にも命を吹き込もう。
19世紀のアメリカではソーダは薬局で売られていたんだよ。
薬剤師が炭酸水にハーブを混ぜて処方していたのが始まりで、
コカ・コーラもペプシも元は薬剤師の栄養ドリンクだったんだ。
● わんぱく「丸太郎」誕生!
放置クリア防止の絶妙な難易度に調整
黄色い丸が赤いハチマキの丸太郎に
メーター満タンでキリッとした顔&炎のオーラ
ふちまでの距離を大幅に拡大
必要チャージ数を3→5へ
■ 実際の動作
まずは、AI が最初に出してくれた試作版のゲーム画面をご紹介します。
大きな泡がどんどん湧き上がり、黄色い丸がその泡に乗るとはじけて飛び上がります。

(画像:AIが最初に出してくれた試作画面)
最初の修正では、泡のサイズを細かくするところから始めました。
ここから炭酸の泡らしい動きを目指して、たくさんの調整を重ねていきます。

(画像:泡サイズを調整した画面)
こちらが仕上がりのゲーム画面です。
主役の丸太郎が、シュワシュワと湧き上がる泡を踏み台にしながら、軽やかに飛び上がっていきます。

(画像:仕上がりのゲーム画面)
泡をうまくとらえてジャンプを続け、
グラスのふちを飛び越えることができたらステージクリアです。

(画像:ステージクリア画面)
ステージをクリアすると、次のステージへ進み、ジュースの色も変わります。
泡をとらえ損ねてコップの底まで落ちてしまうとゲームオーバーです。

(画像:ゲームオーバー画面)
■ コードのポイント
今回の記事では、
AI と相談しながら作った「丸太郎のソーダグラス大脱出」 の HTML コードを公開します。
このコードをそのまま保存するだけで、
あなたの PC でもすぐに遊べます。
ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。
▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く
Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)
② 下のコードをコピーして貼り付けます。
③ 「so-da.html」など、好きな名前で保存します。
(※拡張子は.html にしてください)
④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。
■ 遊び方・操作方法
【基本の動き】
・「左右の矢印キー」または「A・Dキー」で移動します。
丸太郎は放っておくと重力で下に落ちてしまうので、
左右にうまく導いてあげてくださいね。(^^*)
【脱出のコツ:泡チャージ!】
ただフワフワしているだけでは、グラスのふちは飛び越えられません。
底から湧き上がるシュワシュワの泡を「踏み台」にして進みます。
① 泡に飛び乗る!
落下中に下の泡に触れると、
パツン!と弾けて丸太郎が小さくバウンドします。
(※丸太郎が上昇している最中は、泡をすり抜けます)
② パワーを溜める!
泡を割るごとに、丸太郎の頭上にあるメーターが1つずつ溜まっていきます。
落ちないように気をつけながら、
合計「5つ」の泡を踏んでメーターを満タンにしましょう!(・v・)
③ 炎のオーラで大ジャンプ!
メーターが満タンになると、
のんきだった丸太郎が「キリッ」とした顔になり、
気合いの炎オーラをまといます!
この READY状態 で次の泡を踏むと……
ポーン!と特大ジャンプ!!(≧▽≦)
うまくグラスのふち(画面の一番上)まで飛び出せれば、
見事ステージクリアです。
【気をつけて!】
泡を踏み外して、グラスの底深くまで沈んでしまうと
ゲームオーバーになります。(;_;)
泡たちは合体して大きくなったり、
水面に近づくほどフワッと加速したりするので、
動きをよく見て飛び乗ってくださいね。
“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。
▼ここからコード▼(クリックで開く)
<html lang="ja"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>丸太郎のソーダグラス大脱出</title><style> body { margin: 0; padding: 0; min-height: 100vh; background: radial-gradient(circle at 50% 30%, #a8e063 0%, #56ab2f 40%, #1e3c00 100%); color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; display: flex; flex-direction: column; align-items: center; overflow-x: hidden; } /* ヘッダー(タイトル) */ header { width: 100%; text-align: center; padding: 20px 0 10px 0; font-size: 38px; font-weight: bold; color: #a8e063; text-shadow: 0 0 15px rgba(168, 224, 99, 0.6), 2px 2px 4px rgba(0,0,0,0.8); letter-spacing: 2px; } /* メインレイアウト */ #main-layout { display: flex; flex-direction: row; gap: 20px; align-items: stretch; justify-content: center; max-width: 1200px; margin-bottom: 30px; } /* サイドバー共通設定 */ .sidebar { width: 280px; background: rgba(20, 45, 20, 0.75); border: 2px solid rgba(168, 224, 99, 0.5); border-radius: 15px; padding: 20px; box-shadow: 0 8px 25px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 20px; } .sidebar h2 { margin: 0 0 10px 0; font-size: 20px; color: #FFDC00; border-bottom: 2px dashed rgba(168, 224, 99, 0.5); padding-bottom: 5px; } .sidebar p { font-size: 14px; line-height: 1.6; margin: 0; color: #e0f0e0; } .highlight { color: #7FDBFF; font-weight: bold; } /* ランキング */ #ranking-list { list-style: none; padding: 0; margin: 0; } #ranking-list li { display: flex; justify-content: space-between; font-size: 16px; margin-bottom: 8px; background: rgba(0,0,0,0.3); padding: 8px 12px; border-radius: 5px; } #ranking-list li:nth-child(1) { color: #FFDC00; font-weight: bold; font-size: 18px; } #ranking-list li:nth-child(2) { color: #DDDDDD; } #ranking-list li:nth-child(3) { color: #CD7F32; } /* クリアコレクション */ #collection-grid { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-start; } .collection-item { display: flex; flex-direction: column; align-items: center; background: rgba(0,0,0,0.4); padding: 5px; border-radius: 8px; width: 50px; } .pixel-canvas { width: 40px; height: 48px; image-rendering: pixelated; } .loop-count { font-size: 12px; margin-top: 3px; color: #FFDC00; font-weight: bold; } /* ゲームコンテナ */ #game-container { position: relative; width: 440px; height: 720px; } canvas#gameCanvas { background: transparent; filter: drop-shadow(0 10px 20px rgba(0, 30, 0, 0.4)); } #ui { position: absolute; top: 20px; left: 25px; right: 25px; display: flex; justify-content: space-between; font-size: 20px; font-weight: bold; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); pointer-events: none; z-index: 10; } .modal { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; display: none; z-index: 20; background: rgba(20, 45, 20, 0.95); padding: 30px 40px; border-radius: 18px; border: 2px solid #a8e063; box-shadow: 0 0 30px rgba(168, 224, 99, 0.6); min-width: 230px; } .modal h1 { margin: 0 0 10px 0; font-size: 34px; color: #a8e063; } .modal h1.clear-title { color: #FFDC00; } .modal button { margin-top: 15px; padding: 12px 28px; font-size: 18px; cursor: pointer; background: #a8e063; border: none; border-radius: 8px; color: #0f2b00; font-weight: bold; box-shadow: 0 4px 10px rgba(0,0,0,0.4); transition: transform 0.1s, background 0.2s; } .modal button:hover { background: #c8f083; transform: scale(1.05); }</style></head><body><header>丸太郎のソーダグラス大脱出</header><div id="main-layout"> <aside class="sidebar"> <div> <h2>📖 Story</h2> <p> 緑あふれるカフェのテラス席。<br> テーブルに置かれた冷たいソーダグラスの中から、わんぱくな<span class="highlight">「丸太郎」</span>が脱出に挑みます!<br><br> 次々と湧いては弾ける泡を踏み台にして、グラスのふちを目指しましょう。 </p> </div> <div> <h2>🎮 Controls</h2> <p> <span class="highlight">【移動】</span><br> キーボードの「A・D」キー、または「左右の矢印キー」で丸太郎を左右に動かします。<br><br> <span class="highlight">【泡チャージ】</span><br> 落ちながら泡を踏むとパツンと弾けて小さくバウンドし、メーターが溜まります。<br><br> <span class="highlight">【大ジャンプ】</span><br> 泡を5つ踏んで気合い(メーター)が満タンになった状態で次の泡を踏むと、炎のオーラをまとって大バウンド!一気にグラスから脱出しましょう! </p> </div> </aside> <div id="game-container"> <div id="ui"> <div>STAGE <span id="stageNum">1</span></div> <div>Score: <span id="score">0</span></div> </div> <div id="gameover" class="modal"> <h1>GAME OVER</h1> <p style="font-size: 18px;">丸太郎力尽きる…<br>Score: <span id="finalScore">0</span></p> <button onclick="resetGame()">もう一杯(リトライ)</button> </div> <div id="stageclear" class="modal"> <h1 class="clear-title">STAGE CLEAR!</h1> <p style="font-size: 18px;">丸太郎、大脱出成功!</p> <button onclick="nextStage()">次のステージへ</button> </div> <canvas id="gameCanvas" width="440" height="720"></canvas> </div> <aside class="sidebar"> <div> <h2>🏆 Ranking</h2> <ul id="ranking-list"> </ul> </div> <div> <h2>🍹 Cleared Juices</h2> <div id="collection-grid"> </div> </div> </aside></div><script> // ========= ランキング機能 ========= let highScores = [1000, 800, 500, 300, 100]; function updateRanking(newScore) { if (newScore > 0) { highScores.push(newScore); highScores.sort((a, b) => b - a); highScores = highScores.slice(0, 5); } const list = document.getElementById('ranking-list'); list.innerHTML = ''; highScores.forEach((score, index) => { const li = document.createElement('li'); li.innerHTML = `<span>${index + 1}位</span><span>${score} pt</span>`; list.appendChild(li); }); } updateRanking(0); // 初期表示 // ========= コレクション機能 (32bitドット絵風) ========= const juiceColors = ['#00b4fc', '#ff64b4', '#ffc832', '#50e678']; function addJuiceToCollection(stageCleared) { const colorIndex = (stageCleared - 1) % 4; const loopCount = Math.floor((stageCleared - 1) / 4) + 1; const color = juiceColors[colorIndex]; const grid = document.getElementById('collection-grid'); const item = document.createElement('div'); item.className = 'collection-item'; // ドット絵用キャンバス const pxCanvas = document.createElement('canvas'); pxCanvas.className = 'pixel-canvas'; pxCanvas.width = 10; // 10x12 pixels のドット絵 pxCanvas.height = 12; const pctx = pxCanvas.getContext('2d'); // ドット絵を描画 (簡単なグラスの形) // グラスの枠(白)と中身(color) for(let y=0; y<12; y++){ for(let x=0; x<10; x++){ // 枠 if ((x===0 || x===9) && y < 11) { pctx.fillStyle = 'rgba(255,255,255,0.8)'; pctx.fillRect(x,y,1,1); } if (y===11 && x > 0 && x < 9) { pctx.fillStyle = 'rgba(255,255,255,0.8)'; pctx.fillRect(x,y,1,1); } // 中身 if (x > 0 && x < 9 && y > 2 && y < 11) { pctx.fillStyle = color; pctx.fillRect(x,y,1,1); // 適当な泡(ハイライト) if (Math.random() < 0.15) { pctx.fillStyle = 'rgba(255,255,255,0.9)'; pctx.fillRect(x,y,1,1); } } } } item.appendChild(pxCanvas); // 2周目以降は数字を追加 if (loopCount > 1) { const numLabel = document.createElement('div'); numLabel.className = 'loop-count'; numLabel.innerText = 'x' + loopCount; item.appendChild(numLabel); } grid.appendChild(item); } // ========= ゲーム本体のロジック ========= const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const scoreElement = document.getElementById('score'); const stageNumElement = document.getElementById('stageNum'); const gameOverScreen = document.getElementById('gameover'); const stageClearScreen = document.getElementById('stageclear'); const finalScoreElement = document.getElementById('finalScore'); const glassMarginTop = 50; const glassMarginBottom = 60; const glassTopWidth = 370; const glassBottomWidth = 290; const glassTopX1 = (canvas.width - glassTopWidth) / 2; const glassTopX2 = glassTopX1 + glassTopWidth; const glassBottomX1 = (canvas.width - glassBottomWidth) / 2; const glassBottomX2 = glassBottomX1 + glassBottomWidth; const glassBottomY = canvas.height - glassMarginBottom; const rimY = glassMarginTop; const surfaceY = rimY + 150; const MAX_BUBBLE_RADIUS = 30; let animationId; let score = 0; let stage = 1; let isGameOver = false; let isStageClear = false; let audioCtx = null; function initAudio() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); } window.addEventListener('click', initAudio); window.addEventListener('keydown', initAudio); function playPopSound(radius) { if (!audioCtx) return; try { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); const freq = Math.max(400, 2200 - radius * 50); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, audioCtx.currentTime); osc.frequency.exponentialRampToValueAtTime(freq * 1.6, audioCtx.currentTime + 0.025); const vol = Math.min(0.08, 0.02 + radius * 0.002); gain.gain.setValueAtTime(vol, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.025); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.025); } catch(e) {} } function playClearSound() { if (!audioCtx) return; try { const now = audioCtx.currentTime; const notes = [523.25, 659.25, 783.99, 1046.50]; notes.forEach((freq, i) => { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.frequency.value = freq; gain.gain.setValueAtTime(0.08, now + i * 0.08); gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.2); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(now + i * 0.08); osc.stop(now + i * 0.08 + 0.2); }); } catch(e) {} } const keys = { ArrowLeft: false, ArrowRight: false, a: false, d: false }; window.addEventListener('keydown', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = true; }); window.addEventListener('keyup', e => { if(keys.hasOwnProperty(e.key)) keys[e.key] = false; }); const player = { x: canvas.width / 2, y: surfaceY + 220, radius: 13, vx: 0, vy: 0, gravity: 0.24, jumpPower: -10.5, speed: 5.5, charge: 0, maxCharge: 5 }; let bubbles = []; let tinyBubbles = []; let splashParticles = []; let ripples = []; class Bubble { constructor(x, y, radius) { this.x = x; this.y = y; this.radius = radius; this.popped = false; this.merged = false; this.onSurface = false; this.wobbleOffset = Math.random() * Math.PI * 2; this.wobbleSpeed = Math.random() * 0.002 + 0.0015; } update() { if (this.onSurface) { this.surfaceTimer--; if (this.surfaceTimer <= 0) this.popped = true; return; } const stageSpeedBonus = (stage - 1) * 0.12; this.y += -(0.85 + (this.radius * 0.07) + stageSpeedBonus); this.x += Math.sin(Date.now() * this.wobbleSpeed + this.wobbleOffset) * 0.5; if (this.y <= surfaceY) { this.y = surfaceY; this.onSurface = true; this.surfaceTimer = Math.floor(this.radius * 2.2) + 5; this.maxSurfaceTimer = this.surfaceTimer; } } draw(ctx) { if(this.popped || this.merged) return; if (this.onSurface) { const shrink = this.surfaceTimer / this.maxSurfaceTimer; ctx.save(); ctx.translate(this.x, surfaceY); ctx.scale(1 + (1 - shrink)*0.2, shrink); ctx.beginPath(); ctx.arc(0, 0, this.radius, Math.PI, 0, false); ctx.fillStyle = "rgba(255, 255, 255, 0.45)"; ctx.fill(); ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; ctx.lineWidth = this.radius>15?1.5:1; ctx.stroke(); ctx.restore(); } else { ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle = "rgba(255, 255, 255, 0.35)"; ctx.fill(); ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; ctx.lineWidth = this.radius>15?1.5:1; ctx.stroke(); ctx.beginPath(); ctx.arc(this.x - this.radius*0.3, this.y - this.radius*0.3, Math.max(0.5, this.radius*0.2), 0, Math.PI*2); ctx.fillStyle = "rgba(255, 255, 255, 0.85)"; ctx.fill(); } } } class TinyBubble { constructor(x, y) { this.x = x; this.y = y; this.radius = Math.random() * 1.2 + 0.8; this.vx = (Math.random() - 0.5) * 4; this.vy = (Math.random() - 0.5) * 4; this.life = 180; this.absorbed = false; this.wobbleOffset = Math.random() * Math.PI * 2; } update() { this.vx *= 0.90; this.vy *= 0.90; this.x += this.vx + Math.sin(Date.now() * 0.003 + this.wobbleOffset) * 0.2; this.y += this.vy - (0.3 + (this.radius * 0.15)); this.life--; } draw(ctx) { if (this.absorbed) return; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.8, this.life / 30)})`; ctx.fill(); } } class SplashParticle { constructor(x, y, radius) { this.x = x; this.y = y; this.radius = Math.random() * 1.5 + 0.5; this.vx = (Math.random() - 0.5) * (radius * 0.6); this.vy = -(Math.random() * 2.5 + 1.0); this.life = 1.0; } update() { this.x += this.vx; this.y += this.vy; this.vy += 0.18; this.life -= 0.06; } draw(ctx) { if (this.life <= 0) return; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(220, 255, 220, ${this.life})`; ctx.fill(); } } function drawCafeTerraceBackground() { ctx.save(); // 丸テーブルの描画 const tableY = glassBottomY + 12; const tableRx = 195; const tableRy = 48; ctx.beginPath(); ctx.ellipse(canvas.width / 2, tableY + 18, tableRx + 10, tableRy + 5, 0, 0, Math.PI * 2); ctx.fillStyle = "rgba(0, 30, 0, 0.4)"; ctx.fill(); ctx.beginPath(); ctx.ellipse(canvas.width / 2, tableY, tableRx, tableRy, 0, 0, Math.PI * 2); ctx.fillStyle = "#8B5A2B"; ctx.fill(); ctx.strokeStyle = "#5C3A1E"; ctx.lineWidth = 4; ctx.stroke(); ctx.beginPath(); ctx.ellipse(canvas.width / 2, tableY - 2, tableRx - 6, tableRy - 4, 0, 0, Math.PI * 2); const tableGrad = ctx.createRadialGradient(canvas.width/2, tableY - 10, 10, canvas.width/2, tableY, tableRx); tableGrad.addColorStop(0, '#E8D3B9'); tableGrad.addColorStop(0.7, '#C49A6C'); tableGrad.addColorStop(1, '#966F42'); ctx.fillStyle = tableGrad; ctx.fill(); ctx.restore(); } function drawJuiceGlass() { ctx.save(); ctx.beginPath(); ctx.moveTo(glassTopX1, surfaceY); ctx.lineTo(glassTopX2, surfaceY); ctx.lineTo(glassBottomX2, glassBottomY); ctx.lineTo(glassBottomX1, glassBottomY); ctx.closePath(); const colors = [ ['rgba(0, 190, 252, 0.35)', 'rgba(0, 90, 190, 0.55)'], // Blue ['rgba(255, 90, 160, 0.35)', 'rgba(190, 20, 90, 0.55)'], // Strawberry ['rgba(255, 200, 40, 0.35)', 'rgba(210, 100, 0, 0.55)'], // Orange ['rgba(60, 220, 100, 0.35)', 'rgba(10, 130, 50, 0.55)'] // Melon ]; const currentColor = colors[(stage - 1) % colors.length]; const juiceGrad = ctx.createLinearGradient(0, surfaceY, 0, glassBottomY); juiceGrad.addColorStop(0, currentColor[0]); juiceGrad.addColorStop(1, currentColor[1]); ctx.fillStyle = juiceGrad; ctx.fill(); ctx.beginPath(); ctx.moveTo(glassBottomX1, glassBottomY); ctx.lineTo(glassBottomX2, glassBottomY); ctx.lineTo(glassBottomX2 + 6, glassBottomY + 25); ctx.lineTo(glassBottomX1 - 6, glassBottomY + 25); ctx.fillStyle = "rgba(255, 255, 255, 0.25)"; ctx.fill(); ctx.strokeStyle = "rgba(255, 255, 255, 0.6)"; ctx.lineWidth = 2; ctx.stroke(); ctx.beginPath(); ctx.moveTo(glassTopX1, rimY); ctx.lineTo(glassBottomX1, glassBottomY); ctx.lineTo(glassBottomX1 - 6, glassBottomY + 25); ctx.lineTo(glassBottomX2 + 6, glassBottomY + 25); ctx.lineTo(glassBottomX2, glassBottomY); ctx.lineTo(glassTopX2, rimY); ctx.strokeStyle = "rgba(255, 255, 255, 0.75)"; ctx.lineWidth = 3.5; ctx.stroke(); ctx.beginPath(); ctx.ellipse((glassTopX1 + glassTopX2)/2, rimY, glassTopWidth/2, 12, 0, 0, Math.PI * 2); ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; ctx.lineWidth = 3; ctx.stroke(); ctx.fillStyle = "rgba(255, 255, 255, 0.1)"; ctx.fill(); ctx.beginPath(); ctx.moveTo(glassTopX1 + 18, rimY + 10); ctx.lineTo(glassBottomX1 + 14, glassBottomY - 10); ctx.strokeStyle = "rgba(255, 255, 255, 0.45)"; ctx.lineWidth = 4; ctx.stroke(); ctx.restore(); } function drawMarutaro() { ctx.save(); ctx.translate(player.x, player.y); const isReady = player.charge >= player.maxCharge; if (isReady) { ctx.beginPath(); ctx.arc(0, 0, player.radius + 6, 0, Math.PI * 2); ctx.fillStyle = "rgba(0, 255, 204, 0.35)"; ctx.fill(); } ctx.beginPath(); ctx.arc(0, 0, player.radius, 0, Math.PI * 2); ctx.fillStyle = isReady ? "#00FFCC" : "#FFD700"; ctx.fill(); ctx.strokeStyle = "#CC9900"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.beginPath(); ctx.rect(-player.radius, -player.radius * 0.6, player.radius * 2, player.radius * 0.45); ctx.fillStyle = "#FF3333"; ctx.fill(); ctx.beginPath(); ctx.arc(player.radius - 2, -player.radius * 0.4, 3, 0, Math.PI * 2); ctx.fillStyle = "#CC0000"; ctx.fill(); ctx.fillStyle = "#331100"; if (isReady) { ctx.beginPath(); ctx.moveTo(-7, -1); ctx.lineTo(-3, -3); ctx.lineTo(-4, 2); ctx.fill(); ctx.beginPath(); ctx.moveTo(7, -1); ctx.lineTo(3, -3); ctx.lineTo(4, 2); ctx.fill(); ctx.beginPath(); ctx.arc(0, 3, 4, 0, Math.PI, false); ctx.fill(); } else { ctx.beginPath(); ctx.arc(-4, -1, 1.8, 0, Math.PI * 2); ctx.arc(4, -1, 1.8, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(0, 2, 3, 0, Math.PI, false); ctx.fill(); } ctx.restore(); } function drawPlayerMeter() { const barWidth = 32; const barHeight = 6; const barX = player.x - barWidth / 2; const barY = player.y - player.radius - 14; ctx.fillStyle = "rgba(0, 0, 0, 0.65)"; ctx.fillRect(barX, barY, barWidth, barHeight); const fillRatio = player.charge / player.maxCharge; ctx.fillStyle = fillRatio >= 1 ? "#00FFCC" : "#FFDC00"; ctx.fillRect(barX, barY, barWidth * fillRatio, barHeight); ctx.strokeStyle = "rgba(255, 255, 255, 0.85)"; ctx.lineWidth = 1; ctx.strokeRect(barX, barY, barWidth, barHeight); } function spawnBubble() { if (bubbles.length < 90 && Math.random() < 0.45 + (stage * 0.04)) { const radius = Math.random() * 2 + 2; const minX = glassBottomX1 + radius + 10; const maxX = glassBottomX2 - radius - 10; let x = Math.random() * (maxX - minX) + minX; let y = (Math.random() < 0.3) ? surfaceY + Math.random() * (glassBottomY - surfaceY - 20) : glassBottomY - radius; bubbles.push(new Bubble(x, y, radius)); } } function checkClearCondition() { if (player.y < rimY - 10 && !isStageClear && !isGameOver) { isStageClear = true; playClearSound(); addJuiceToCollection(stage); // クリアしたジュースを右サイドバーに追加 stageClearScreen.style.display = 'block'; } } function nextStage() { stage++; stageNumElement.innerText = stage; stageClearScreen.style.display = 'none'; isStageClear = false; resetPlayerAndBubbles(); loop(); } function resetGame() { // リトライ時はランキングを更新しない(ゲームオーバー時に更新済み) document.getElementById('collection-grid').innerHTML = ''; // コレクションリセット stage = 1; score = 0; stageNumElement.innerText = stage; scoreElement.innerText = score; isGameOver = false; isStageClear = false; gameOverScreen.style.display = 'none'; stageClearScreen.style.display = 'none'; resetPlayerAndBubbles(); loop(); } function resetPlayerAndBubbles() { player.x = canvas.width / 2; player.y = surfaceY + 220; player.vy = 0; player.charge = 0; bubbles = []; tinyBubbles = []; splashParticles = []; for(let i=0; i<50; i++) { const minX = glassBottomX1 + 10; const maxX = glassBottomX2 - 10; const x = Math.random() * (maxX - minX) + minX; const y = surfaceY + Math.random() * (glassBottomY - surfaceY - 20); bubbles.push(new Bubble(x, y, Math.random() * 2 + 2)); } } function loop() { if(isGameOver || isStageClear) return; ctx.clearRect(0, 0, canvas.width, canvas.height); drawCafeTerraceBackground(); drawJuiceGlass(); if (keys.ArrowLeft || keys.a) player.x -= player.speed; if (keys.ArrowRight || keys.d) player.x += player.speed; const currentGlassLeft = glassTopX1 + (glassBottomX1 - glassTopX1) * ((player.y - rimY) / (glassBottomY - rimY)); const currentGlassRight = glassTopX2 + (glassBottomX2 - glassTopX2) * ((player.y - rimY) / (glassBottomY - rimY)); if (player.x < currentGlassLeft + player.radius) player.x = currentGlassLeft + player.radius; if (player.x > currentGlassRight - player.radius) player.x = currentGlassRight - player.radius; player.vy += player.gravity; player.y += player.vy; spawnBubble(); // TinyBubbles & Splashes (省略せず処理) for (let i = tinyBubbles.length - 1; i >= 0; i--) { tinyBubbles[i].update(); tinyBubbles[i].draw(ctx); if (tinyBubbles[i].life <= 0 || tinyBubbles[i].y <= surfaceY) tinyBubbles.splice(i, 1); } for (let i = splashParticles.length - 1; i >= 0; i--) { splashParticles[i].update(); splashParticles[i].draw(ctx); if (splashParticles[i].life <= 0) splashParticles.splice(i, 1); } for (let i = bubbles.length - 1; i >= 0; i--) { let b = bubbles[i]; b.update(); b.draw(ctx); if (player.vy > 0 && !b.popped) { let checkY = b.onSurface ? surfaceY : b.y; let dx = player.x - b.x; let dy = player.y - checkY; if (Math.sqrt(dx*dx + dy*dy) < player.radius + b.radius + 8 && player.y < checkY) { b.popped = true; // 分裂処理 for(let k=0; k<4; k++) tinyBubbles.push(new TinyBubble(b.x, checkY)); if (b.onSurface) { for(let k=0; k<5; k++) splashParticles.push(new SplashParticle(b.x, surfaceY, b.radius)); playPopSound(b.radius); } else { playPopSound(b.radius); } if (player.charge < player.maxCharge) { player.charge++; score += 2; } else { player.vy = player.jumpPower; player.charge = 0; score += Math.floor(b.radius) + 15; } scoreElement.innerText = score; } } if (b.popped) bubbles.splice(i, 1); } checkClearCondition(); drawMarutaro(); drawPlayerMeter(); if (player.y > glassBottomY + 40) { isGameOver = true; updateRanking(score); // ゲームオーバー時にランキング更新 finalScoreElement.innerText = score; gameOverScreen.style.display = 'block'; } if(!isGameOver && !isStageClear) { animationId = requestAnimationFrame(loop); } } resetPlayerAndBubbles(); loop();</script></body></html>
■ 今日の学び
以前、「文字ストローで炭酸ジュースを飲む」という
実験ゲームを作ったことがありました。
そのときからずっと、
「あのシュワシュワ湧き上がる泡の上に、もしピョンピョン乗れたら楽しいだろうなぁ… ( ☉_☉) ハッ!」
という妄想が頭の片すみにあり、今回のゲーム作りがスタートしました。
一番のこだわりは、なんといっても 炭酸の泡のリアリティ です。
どうすればグラスの中の本物っぽさや、
水面で弾ける気持ちよさが出るのか。
AIに何度も相談しながら、
サイズ・浮力・弾ける瞬間の動きまで細かく調整を重ねました。
(泡の動きを見つめすぎて、気が遠くなるかと思いました… ꜀( ꜆-ࡇ-)꜆ )
そして、そんなこだわりのグラスからの脱出に挑むのは、
以前のゲームにも登場した 丸太郎 です。
シンプルな丸いキャラクターですが、
最後に赤いハチマキを巻いた主人公として抜擢したら、
ゲーム全体がグッと生き生きと動き始めました! ₍₍ ٩( ˙ω˙ )و ⁾⁾🔥
また最近は、ゲームを作るときに
AI に「テーマにまつわる雑学」を教えてもらい、
それをゲーム内にそっと添えるようにしています。
制作の途中で AI がポロッと教えてくれる裏話や知識が、
とても面白いんです。
ゲームの楽しさと一緒に、
その「新しいことを知るワクワク感」も、遊んでくれる皆さんへ
少しでもおすそ分けできたら嬉しいなと思っています。( ¯꒳¯ )✨
■ 次回は、スイカと魚のちょっぴりスリリングな
アクションシューティングゲームです。
大好きなスイカのみずみずしい赤と緑、
そして涼しげな清流の世界観を画面いっぱいに詰め込んだ、
実験ゲームを作ってみました。
寄ってくるお腹を空かせたお魚たちに、
スイカを割って果肉を届けて満腹にしてあげるゲームです。
ピコピコと懐かしいBGMと一緒に、
果汁が弾ける爽快感と、
囲まれるちょっとしたドキドキ感が楽しめる作品になっています。(*^_^*)
次回もぜひ楽しみにしていてくださいね。(*^v^)ノシ
▶ 次の実験はこちら
清流でスイカ割りサバイバル
「WATERMELON SURVIVAL」
✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)






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