実験#97:絵本のような世界(茶虫くんの薬草茶の旅 〜 水たまり大作戦 〜)

■97個目 「茶虫くんの薬草茶の旅 〜 水たまり大作戦 〜」

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

今回は、ちょっと不思議で
とっても可愛いキャラクターが主役のゲームを作ってみました。
その名も 「茶虫(ちゃむし)くん」 です!

ぽってりとした緑色の体に、頭には大きな葉っぱの触覚。
そして両手には、湯気がほわほわと立ちのぼる「茶」の文字入りの湯呑み。

おっとりしているけれど、
薬草探しとなると目を輝かせる、
そんな子です。(=v=*)

■ 雨上がりの大ピンチ!

ある晴れた日のこと。

茶虫くんのお散歩コースに、
なんと 巨大な水たまり ができてしまいました。

このままでは、大好きな薬草探しの旅に進めません。

そこで、皆さんの出番です!

■ 葉っぱを浮かべて、道をつくろう

茶虫くんが好きな「薬草の葉っぱ」を水面に浮かべて、
対岸までの道を作ってあげましょう。

登場する葉っぱは全部で12種類。
イタドリ、クワ、ビワ、そしてちょっぴり苦いゴーヤまで、
個性豊かな薬草たちが勢ぞろい。

葉っぱを踏むたびに、茶虫くんは 薬草茶パワー を吸収して元気いっぱい!
特有の「ぬるぬるっ」とした動きで、どんどんスピードアップしていきます。

■ もし落ちちゃっても大丈夫

もし葉っぱの道が途切れて、水たまりにポチャリ……。
そんな時でも安心してください。(^^*)

なんと、#18:”早口実況アメンボレース”のアメンボたちで、

水面をツツーイと滑っていた
アカアシアオアメンボ、アメマアタマアメンボ、
アメアガリアメンボ、アクマアザアメンボ の4匹が、

レースを中断して大急ぎで救出に来てくれます。

誰も傷つかない、やさしい世界での小さな大冒険です。

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

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

みー:(^v^) 今日もゲームを作ります。

まずはキャラクターから。

薬草茶のキャラクター「茶虫くん」を作りたいです。

青虫っぽい丸い顔で緑色。
黒い点と線の落ち着いた目。
中央に濃い緑の団子鼻、口はちんまり。
頭には柄付き葉っぱの触覚みたいな飾り。
湯呑のお茶を持っていて、ほっぺはほんのり赤色。

この特徴で、図形を組み合わせてキャラを作ってみて。

AI: ゲーム作成、素晴らしいですね!
ご指定の特徴をもとに「茶虫くん」を作成しました。

緑色の丸い顔に点と線の目、葉っぱの触覚、
湯呑みを持った可愛いキャラクターになりました。

(※生成された茶虫くんの画像)

AI: この茶虫くんを使って、どんなゲームにしていきましょう?
次のステップを教えてください。

みー:(^o^/) 茶虫くんの動きはリアルにしたいです。
イモムシの「ぬるっ」とした、
本物みたいで二度見しちゃう動きを再現してほしい。

まずは葉っぱの上を歩かせたいです。

AI: 動画の準備ができました!

……と言いたいところなのですが、
(※ブログづくりのために読み返していたら、
動画の負荷でチャット履歴が全部消える大アクシデント発生!)
~動画自体はXに投稿してあります。

みー:(;_;) AIとのやり取りのパートを仕上げようとしたところで、
履歴が消えちゃって困ってました……。

でも、さっきまで作っていたコードと下書きは
保存してあったので貼り付けますね!

AI: うわあ、バックアップを残していて本当に良かったです!
ナイスリカバリーです。

お預かりしたコード、薬草のセレクトが渋くて最高ですし、
前作「アメンボレース」の4匹が救出隊として登場する
伏線回収には胸が熱くなりました!

みー:(^_^;) よかったです!それでは気を取り直して続きます。

AI: ……あ、それとみーさん。
保存してくれていた操作説明の文章を読んでいたら、
なぜか別のゲームの
「クレーンが自動で動いて素材を投下するルール」が
紛れ込んでましたよ!(笑)

〈あわてて前回の記事のパートも混ぜて貼り付けてました〉
_(め_ゅ)_

■ 実際の動作

まずは完成したスタート画面から。
ここで世界観とゲームの遊び方をまとめて確認できます。

(画像:スタート画面)



茶虫くんは、水たまりの岸から冒険をスタートします。
水面には、なんとレース中のアメンボたちがスイスイと滑走中。
#18「早口実況アメンボレース」に登場した4匹です!)

(画像:ゲーム画面)



● 葉っぱをドラッグして道をつくろう

プレイヤーは、薬草の葉っぱをドラッグして
茶虫くんの通り道を作っていきます。

薬草茶の上を通ると、茶虫くんは 薬草茶パワー を吸収して元気いっぱい。
ぬるっとした独特の動きで、どんどんスピードアップしていきます。

隙間を作らないように、テンポよく葉っぱを並べていきましょう。
目指すは水たまりの反対側!

(画像:ゲーム画面)


● 落ちてしまったら……

葉っぱをうまくつなげられず、茶虫くんが水たまりにポチャリ……。
その瞬間、ゲームは終了です。

(画像:ゲーム終了とリスタート)

でも大丈夫。
何度でも挑戦できるので、ぜひベストルートを見つけてみてください。


■ コードのポイント

今回の記事では、
AI と相談しながら作った「茶虫くんの薬草茶の旅 〜 水たまり大作戦 〜」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 操作方法

今回のゲームも操作はとっても簡単! マウスだけで遊べます。(^^*)

● 基本操作

  • 左クリック & ドラッグ:薬草の葉っぱをつかんで移動
  • ドロップ:葉っぱを水面に置く

これだけで OK です。

💡 基本のルール

  1. 薬草を選ぶ
    画面上部の“太陽みたいな薬草トレイ”から、
    使いたい葉っぱをクリックします。
  2. 葉っぱを置く(ドラッグ&ドロップ)
    選んだ葉っぱを水面へドラッグして、茶虫くんの通り道を作ります。
  3. 茶虫くんが進む(自動)
    茶虫くんは左から右へ自動で移動します。
    葉っぱを踏むと 薬草茶パワー を吸収してスピードアップ!
  4. 道が途切れると……?
    葉っぱがつながっていない場所に来ると、
    茶虫くんは水たまりにポチャリ。
  5. 対岸にたどり着けばクリア!
    すべての葉っぱを渡りきるとステージクリアです。

ちょっとハラハラする、茶虫くんの小さな大冒険。
ぜひ楽しんでみてくださいね。ヽ(^^*)

(画像:操作説明図)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>茶虫くんの薬草茶の旅 〜 水たまり大作戦 〜</title>
<style>
* { box-sizing: border-box; user-select: none; }
body {
margin: 0;
padding: 0;
background-color: #e3ebd5;
font-family: 'Helvetica Neue', Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #2c1810;
}
#gameContainer {
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-radius: 16px;
overflow: hidden;
background-color: #fff;
}
canvas {
display: block;
background-color: #faf6ee;
}
.ui-overlay {
position: absolute;
top: 20px;
left: 20px;
font-size: 18px;
font-weight: bold;
padding: 10px 20px;
border-radius: 30px;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 5;
}
.ui-row {
display: flex;
gap: 20px;
}
.game-status {
color: #2d5a27;
text-shadow: 1px 1px 1px rgba(255,255,255,0.8);
}
.stage-status {
color: #e65100;
font-size: 24px;
text-shadow: 1px 1px 2px rgba(255,255,255,0.9);
}
.goal-status {
color: #1565c0;
font-size: 16px;
background: rgba(255,255,255,0.7);
padding: 4px 10px;
border-radius: 10px;
}
.drag-guide {
position: absolute;
top: 52px;
right: 185px;
background: rgba(255, 255, 255, 0.95);
color: #d84315;
padding: 8px 18px;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
border: 2px solid #ff9800;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
pointer-events: none;
animation: pointRight 1s ease-in-out infinite alternate;
z-index: 5;
}
@keyframes pointRight {
0% { transform: translateX(0); }
100% { transform: translateX(8px); }
}
#msgScreen {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(45, 90, 39, 0.85);
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
transition: all 0.3s;
z-index: 10;
}
#msgScreen h2 {
font-size: 36px;
margin-bottom: 10px;
color: #fff3dd;
}
#msgScreen h3 {
color: #ffb74d;
margin-top: 0;
}
#msgScreen p {
font-size: 18px;
max-width: 600px;
line-height: 1.6;
margin-bottom: 30px;
}
.btn {
background-color: #ffb74d;
color: #2c1810;
border: none;
padding: 12px 35px;
font-size: 20px;
font-weight: bold;
border-radius: 30px;
cursor: pointer;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
transition: transform 0.1s;
}
.btn:hover {
background-color: #ffa726;
transform: scale(1.05);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas" width="960" height="540"></canvas>
<div class="ui-overlay" id="uiOverlay">
<div class="stage-status">Stage: <span id="stageVal">1</span></div>
<div class="ui-row">
<div>スコア: <span id="scoreVal" class="game-status">0</span></div>
<div>速度: <span id="speedVal" class="game-status">100%</span></div>
<div>次の薬草: <span id="nextHerbVal" style="color:#d84315;">選別中</span></div>
</div>
<div class="goal-status">目標速度: <span id="goalSpeedVal">300</span>% で対岸へ到達!</div>
</div>
<div id="dragGuide" class="drag-guide hidden">
薬草をここからドラッグ! 👉
</div>
<!-- スタート / ゲームオーバー / クリア 画面 -->
<div id="msgScreen">
<h2 id="screenTitle">茶虫くんの薬草茶の旅</h2>
<h3 id="screenSubTitle">〜 水たまり大作戦 〜</h3>
<p id="screenDesc">
画面左からぬるぬると進んでくる「茶虫くん」。目の前には大きな水たまりが広がっています!<br>
上部の薬草トレイ(太陽)から、マウスで薬草の葉をつかんで水面に並べ、道を作ってあげましょう。<br>
茶虫くんが葉っぱを踏むと、薬草茶パワーでどんどんスピードアップします。指定の目標速度に達すると対岸が見えてきます!
</p>
<button id="startBtn" class="btn">出発する</button>
</div>
</div>
<script>
// ==========================================
// ピコピコ 8-bit サウンドエンジン (Web Audio API)
// ==========================================
const SoundEngine = (function() {
let ctx = null;
let bgmInterval = null;
let isPlaying = false;
let tempo = 0.4;
const yonanukiScale = [329.63, 392.00, 440.00, 493.88, 587.33, 659.25];
const init = () => {
if (!ctx) {
ctx = new (window.AudioContext || window.webkitAudioContext)();
}
if (ctx.state === 'suspended') ctx.resume();
};
const playTone = (freq, type, startTimeOffset, dur, vol = 0.1) => {
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime + startTimeOffset);
gain.gain.setValueAtTime(vol, ctx.currentTime + startTimeOffset);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + startTimeOffset + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + startTimeOffset);
osc.stop(ctx.currentTime + startTimeOffset + dur);
};
const loopBGM = () => {
if (!isPlaying) return;
const freq = yonanukiScale[Math.floor(Math.random() * yonanukiScale.length)];
playTone(freq, 'triangle', 0, tempo * 0.8, 0.05);
bgmInterval = setTimeout(loopBGM, tempo * 1000);
};
return {
startBGM: () => {
init();
if (!isPlaying) {
isPlaying = true;
loopBGM();
}
},
stopBGM: () => {
isPlaying = false;
if (bgmInterval) clearTimeout(bgmInterval);
},
updateSpeed: (speedRatio) => {
tempo = Math.max(0.08, 0.5 / speedRatio);
},
seGrab: () => { init(); playTone(600, 'square', 0, 0.05, 0.05); },
sePlace: () => { init(); playTone(300, 'square', 0, 0.1, 0.05); playTone(400, 'square', 0.05, 0.1, 0.05); },
seStep: () => { init(); playTone(880, 'square', 0, 0.05, 0.08); },
seSplash: () => {
init();
playTone(150, 'sawtooth', 0, 0.4, 0.15);
playTone(100, 'sawtooth', 0.1, 0.4, 0.15);
playTone(50, 'sawtooth', 0.2, 0.4, 0.15);
},
seClear: () => {
init();
[523.25, 659.25, 783.99, 1046.50].forEach((f, i) => {
playTone(f, 'square', i * 0.12, 0.3, 0.1);
});
}
};
})();
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const uiOverlay = document.getElementById('uiOverlay');
const dragGuide = document.getElementById('dragGuide');
const msgScreen = document.getElementById('msgScreen');
const screenTitle = document.getElementById('screenTitle');
const screenSubTitle = document.getElementById('screenSubTitle');
const screenDesc = document.getElementById('screenDesc');
const startBtn = document.getElementById('startBtn');
const scoreVal = document.getElementById('scoreVal');
const speedVal = document.getElementById('speedVal');
const nextHerbVal = document.getElementById('nextHerbVal');
const stageVal = document.getElementById('stageVal');
const goalSpeedVal = document.getElementById('goalSpeedVal');
uiOverlay.classList.add('hidden');
const HERB_TYPES = [
{ name: "イタドリ", color: "#aed581", size: 120, bonus: 0.05 },
{ name: "ドクダミ", color: "#33691e", size: 110, bonus: 0.08 },
{ name: "クワの葉", color: "#689f38", size: 130, bonus: 0.06 },
{ name: "杜仲茶", color: "#8d6e63", size: 115, bonus: 0.09 },
{ name: "ビワの葉", color: "#558b2f", size: 150, bonus: 0.07 },
{ name: "カキの葉", color: "#9ccc65", size: 140, bonus: 0.06 },
{ name: "スギナ", color: "#c5e1a5", size: 110, bonus: 0.10 },
{ name: "モリンガ", color: "#7cb342", size: 110, bonus: 0.12 },
{ name: "パパイヤ葉", color: "#2e7d32", size: 145, bonus: 0.11 },
{ name: "イチョウ葉", color: "#fbc02d", size: 120, bonus: 0.08 },
{ name: "石トリソウ", color: "#1b5e20", size: 100, bonus: 0.14 },
{ name: "ゴーヤ", color: "#1b5e20", size: 130, bonus: 0.20 }
];
// 新たなステート RESCUING を追加
let gameState = "START";
let score = 0;
let totalScore = 0;
let currentStage = 1;
let baseSpeed = 1.0;
let currentSpeed = baseSpeed;
let time = 0;
let rescueTimer = 0;
let leaves = [];
let ripples = [];
let steams = [];
let currentNextHerb = null;
let isDragging = false;
let mouseX = 0;
let mouseY = 0;
const trayY = 70;
const trayX = 800;
const trayRadius = 35;
const chamushiX = 250;
const chamushiY = 320;
const segmentsCount = 7;
const segmentSpacing = 22;
let bodyPositions = [];
let landX = 0;
let waterScroll = 0;
let targetSpeedPercent = 300;
let goalX = -1000;
let isGoalVisible = false;
let racingGerrids = [
{ id: 0, x: 100, y: 135, type: 0, v: 0, glideTimer: 0 },
{ id: 1, x: 250, y: 145, type: 1, v: 0, glideTimer: 0 },
{ id: 2, x: 400, y: 155, type: 2, v: 0, glideTimer: 0 },
{ id: 3, x: 550, y: 165, type: 3, v: 0, glideTimer: 0 },
];
function drawGerridShape(x, y, type) {
ctx.save();
ctx.translate(x, y);
ctx.scale(0.8, 0.8);
let bodyColor = '#00695c';
if (type === 0) bodyColor = '#00838f';
if (type === 2) {
let grad = ctx.createLinearGradient(-15, -10, 15, 10);
grad.addColorStop(0, '#e91e63');
grad.addColorStop(0.3, '#3f51b5');
grad.addColorStop(0.6, '#4caf50');
grad.addColorStop(1, '#ffeb3b');
bodyColor = grad;
}
ctx.fillStyle = bodyColor;
ctx.beginPath(); ctx.ellipse(0, 0, 15, 8, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(-5, -5, 2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(5, -5, 2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(-5, -5, 1, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(5, -5, 1, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#2c1810'; ctx.lineWidth = 1.5;
if (type === 0) ctx.strokeStyle = '#c62828';
ctx.beginPath(); ctx.moveTo(-10, 5); ctx.lineTo(-20, 15); ctx.lineTo(-15, 20); ctx.stroke();
ctx.beginPath(); ctx.moveTo(10, 5); ctx.lineTo(20, 15); ctx.lineTo(15, 20); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-5, 8); ctx.lineTo(-15, 25); ctx.lineTo(-10, 30); ctx.stroke();
ctx.beginPath(); ctx.moveTo(5, 8); ctx.lineTo(15, 25); ctx.lineTo(10, 30); ctx.stroke();
if (type === 1) {
ctx.fillStyle = '#fbc02d';
ctx.beginPath(); ctx.arc(0, -5, 6, 0, Math.PI * 2); ctx.fill();
} else if (type === 3) {
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(-7, 3, 2, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(7, 3, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
}
function updateAndDrawRacingGerrids() {
for (let i = 0; i < racingGerrids.length; i++) {
let g = racingGerrids[i];
if (gameState === "RESCUING" || gameState === "GAMEOVER") {
// ★ 救出モード:茶虫くんの周りに集まる
let targetX = chamushiX + (i - 1.5) * 40;
let targetY = chamushiY + 15;
g.rescueX += (targetX - g.rescueX) * 0.1;
g.rescueY += (targetY - g.rescueY) * 0.1;
drawGerridShape(g.rescueX, g.rescueY, g.type);
} else {
// 通常のレースモード
if (g.glideTimer > 0) {
g.glideTimer--;
g.v *= 0.98;
} else {
g.v = 2 + Math.random() * 3;
g.glideTimer = 30 + Math.random() * 60;
if (g.v < 0.2) g.v = 0.2;
}
g.x += g.v;
let finalX = (g.x + waterScroll) % (canvas.width + 100) - 50;
drawGerridShape(finalX, g.y, g.type);
}
}
}
function initGame(isFullReset = false) {
if (isFullReset) {
score = 0;
totalScore = 0;
currentStage = 1;
targetSpeedPercent = 300;
} else {
score = 0;
currentStage++;
targetSpeedPercent = 300 + (currentStage - 1) * 10;
}
currentSpeed = baseSpeed;
time = 0;
rescueTimer = 0;
landX = 0;
waterScroll = 0;
leaves = [];
ripples = [];
steams = [];
isDragging = false;
isGoalVisible = false;
goalX = -1000;
stageVal.textContent = currentStage;
goalSpeedVal.textContent = targetSpeedPercent;
scoreVal.textContent = totalScore;
speedVal.textContent = "100%";
leaves.push({ x: chamushiX + 60, y: chamushiY, type: HERB_TYPES[2], stepped: false });
leaves.push({ x: chamushiX + 180, y: chamushiY, type: HERB_TYPES[4], stepped: false });
leaves.push({ x: chamushiX + 300, y: chamushiY, type: HERB_TYPES[5], stepped: false });
chooseNextHerb();
bodyPositions = [];
for(let i=0; i<segmentsCount; i++) {
bodyPositions.push({x: chamushiX - i*segmentSpacing, y: chamushiY});
}
}
function chooseNextHerb() {
const idx = Math.floor(Math.random() * HERB_TYPES.length);
currentNextHerb = HERB_TYPES[idx];
nextHerbVal.textContent = currentNextHerb.name;
nextHerbVal.style.color = currentNextHerb.color;
}
startBtn.addEventListener('click', () => {
if (gameState === "START" || gameState === "GAMEOVER") {
initGame(true);
startGame();
} else if (gameState === "CLEAR") {
initGame(false);
startGame();
}
});
function startGame() {
gameState = "PLAYING";
msgScreen.classList.add('hidden');
uiOverlay.classList.remove('hidden');
dragGuide.classList.remove('hidden');
SoundEngine.startBGM();
SoundEngine.updateSpeed(currentSpeed / baseSpeed);
}
function getMousePos(e) {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
canvas.addEventListener('mousedown', (e) => {
if (gameState !== "PLAYING") return;
const pos = getMousePos(e);
const dist = Math.hypot(pos.x - trayX, pos.y - trayY);
if (dist < trayRadius + 10) {
isDragging = true;
mouseX = pos.x;
mouseY = pos.y;
SoundEngine.seGrab();
}
});
canvas.addEventListener('mousemove', (e) => {
const pos = getMousePos(e);
mouseX = pos.x;
mouseY = pos.y;
});
canvas.addEventListener('mouseup', (e) => {
if (!isDragging) return;
isDragging = false;
const pos = getMousePos(e);
if (pos.y > 140 && pos.x > 0 && pos.x < canvas.width) {
leaves.push({ x: pos.x, y: chamushiY, type: currentNextHerb, stepped: false });
ripples.push({x: pos.x, y: chamushiY, r: 10, alpha: 1});
chooseNextHerb();
SoundEngine.sePlace();
}
});
function drawBackground() {
let skyBarH = 130;
let skyGrad = ctx.createLinearGradient(0, 0, 0, skyBarH);
skyGrad.addColorStop(0, '#87CEEB');
skyGrad.addColorStop(1, '#E0F7FA');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, canvas.width, skyBarH);
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.beginPath(); ctx.ellipse(120, 60, 45, 28, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(150, 50, 35, 25, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(420, 80, 60, 35, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(450, 70, 40, 30, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(680, 50, 40, 25, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#2d5a27';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(0, skyBarH); ctx.lineTo(canvas.width, skyBarH); ctx.stroke();
let puddleGrad = ctx.createLinearGradient(0, skyBarH, 0, canvas.height);
puddleGrad.addColorStop(0, '#4fc3f7');
puddleGrad.addColorStop(0.5, '#29b6f6');
puddleGrad.addColorStop(1, '#0288d1');
ctx.fillStyle = puddleGrad;
ctx.fillRect(0, skyBarH, canvas.width, canvas.height - skyBarH);
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.lineWidth = 3;
for(let i=0; i<15; i++) {
let lineX = (i * 80 + waterScroll) % (canvas.width + 100) - 50;
let lineY = 160 + (i * 25) % (canvas.height - 160);
ctx.beginPath();
ctx.moveTo(lineX, lineY);
ctx.lineTo(lineX + 40, lineY);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, chamushiY); ctx.lineTo(canvas.width, chamushiY); ctx.stroke();
updateAndDrawRacingGerrids();
if (chamushiX + 60 + landX > -200) {
ctx.fillStyle = '#8bc34a';
ctx.beginPath();
ctx.moveTo(0, 120);
let landEdgeX = Math.max(0, chamushiX + 20 + landX);
ctx.lineTo(landEdgeX, 120);
ctx.quadraticCurveTo(chamushiX + 60 + landX, chamushiY + 80, chamushiX - 120 + landX, canvas.height);
ctx.lineTo(0, canvas.height);
ctx.fill();
ctx.strokeStyle = '#558b2f';
ctx.lineWidth = 4;
ctx.stroke();
}
if (isGoalVisible) {
ctx.fillStyle = '#7cb342';
ctx.beginPath();
ctx.moveTo(canvas.width, 120);
ctx.lineTo(goalX, 120);
ctx.quadraticCurveTo(goalX - 80, chamushiY + 50, goalX + 50, canvas.height);
ctx.lineTo(canvas.width, canvas.height);
ctx.fill();
ctx.strokeStyle = '#33691e';
ctx.lineWidth = 4;
ctx.stroke();
ctx.fillStyle = '#795548';
ctx.fillRect(goalX + 40, 200, 10, 120);
ctx.fillStyle = '#ffecb3';
ctx.fillRect(goalX, 150, 120, 60);
ctx.strokeStyle = '#5d4037';
ctx.lineWidth = 3;
ctx.strokeRect(goalX, 150, 120, 60);
ctx.fillStyle = '#e65100';
ctx.font = 'bold 20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('GOAL!', goalX + 60, 185);
}
let sunGlow = ctx.createRadialGradient(trayX, trayY, 5, trayX, trayY, trayRadius + 18);
sunGlow.addColorStop(0, 'rgba(255, 215, 0, 0.4)');
sunGlow.addColorStop(0.7, 'rgba(255, 165, 0, 0.1)');
sunGlow.addColorStop(1, 'rgba(255, 165, 0, 0)');
ctx.fillStyle = sunGlow;
ctx.beginPath(); ctx.arc(trayX, trayY, trayRadius + 18, 0, Math.PI*2); ctx.fill();
let sunGrad = ctx.createRadialGradient(trayX, trayY, 5, trayX, trayY, trayRadius);
sunGrad.addColorStop(0, '#FFD700');
sunGrad.addColorStop(1, '#FF8C00');
ctx.fillStyle = sunGrad;
ctx.beginPath(); ctx.arc(trayX, trayY, trayRadius, 0, Math.PI*2); ctx.fill();
if (!isDragging && currentNextHerb) {
drawLeafShape(trayX, trayY, currentNextHerb, true);
}
}
function drawLeafShape(x, y, herb, isPreview) {
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = herb.color;
const scale = isPreview ? 0.3 : 1.0;
ctx.scale(scale, scale);
ctx.beginPath();
if (herb.name === "イチョウ葉") {
ctx.moveTo(0, 15); ctx.quadraticCurveTo(-35, -20, -40, -40); ctx.quadraticCurveTo(0, -50, 0, -25);
ctx.quadraticCurveTo(0, -50, 40, -40); ctx.quadraticCurveTo(35, -20, 0, 15);
} else if (herb.name === "ゴーヤ") {
ctx.ellipse(0, 0, herb.size, herb.size * 0.5, 0, 0, Math.PI * 2);
} else if (herb.name === "パパイヤ葉") {
for (let i = 0; i < 7; i++) {
let angle = (Math.PI * 2 / 7) * i;
ctx.lineTo(Math.cos(angle) * herb.size, Math.sin(angle) * herb.size);
ctx.lineTo(Math.cos(angle + 0.4) * (herb.size*0.4), Math.sin(angle + 0.4) * (herb.size*0.4));
}
} else {
ctx.moveTo(-herb.size, 0); ctx.quadraticCurveTo(0, -herb.size*0.6, herb.size, 0); ctx.quadraticCurveTo(0, herb.size*0.6, -herb.size, 0);
}
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = isPreview ? 3 : 4;
ctx.beginPath(); ctx.moveTo(-herb.size, 0); ctx.lineTo(herb.size, 0);
for (let i = -herb.size + 15; i < herb.size; i += 25) {
ctx.moveTo(i, 0); ctx.lineTo(i + 15, -herb.size * 0.3); ctx.moveTo(i, 0); ctx.lineTo(i + 15, herb.size * 0.3);
}
ctx.stroke();
if (!isPreview) {
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(herb.name, 0, 5);
}
ctx.restore();
}
function updateAndDrawRipples() {
for (let i = ripples.length - 1; i >= 0; i--) {
let r = ripples[i];
r.r += 1.5; r.alpha -= 0.02;
if (gameState === "PLAYING" && !isGoalVisible) r.x -= currentSpeed;
if (r.alpha <= 0) { ripples.splice(i, 1); continue; }
ctx.strokeStyle = `rgba(255,255,255,${r.alpha})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(r.x, r.y, r.r, 0, Math.PI*2); ctx.stroke();
}
}
function createSteam(x, y) {
steams.push({
x: x + (Math.random() - 0.5) * 10,
y: y,
vx: (Math.random() - 0.5) * 0.5,
vy: -1 - Math.random() * 1,
life: 60 + Math.random() * 30,
maxLife: 90
});
}
function updateAndDrawSteams() {
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
for (let i = steams.length - 1; i >= 0; i--) {
let s = steams[i];
s.x += s.vx;
s.y += s.vy;
s.life--;
if (gameState === "PLAYING" && !isGoalVisible) s.x -= currentSpeed * 0.2;
if (s.life <= 0) {
steams.splice(i, 1);
continue;
}
let alpha = Math.max(0, (s.life / s.maxLife) * 0.5);
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.beginPath();
let size = Math.max(1, 10 * (s.life / s.maxLife));
ctx.arc(s.x, s.y, size, 0, Math.PI * 2);
ctx.fill();
}
}
function updateAndDrawChamushi() {
if (bodyPositions.length === 0) return;
let uNeriSpeed = (gameState === "GOAL_REACHED") ? 0.02 : time * 5;
// ★ 救出時は少し水に沈む
let sinkOffset = (gameState === "RESCUING" || gameState === "GAMEOVER") ? 15 : 0;
for (let i = 0; i < segmentsCount; i++) {
const wavePhase = (gameState === "GOAL_REACHED") ? (time*2 - i*1.1) : (uNeriSpeed - i * 1.1);
const stretch = Math.sin(wavePhase) * 14;
// ★ 救出時は持ち上がらずプカプカするだけ
let lift = 0;
if (gameState === "RESCUING" || gameState === "GAMEOVER") {
lift = Math.sin(time * 5) * 3;
} else {
lift = Math.max(0, Math.sin(wavePhase)) * -22;
}
if (gameState === "GOAL_REACHED") {
bodyPositions[i].x = chamushiX + ((time - goalReachedTime) * 30) - (i * segmentSpacing) + stretch;
} else {
bodyPositions[i].x = chamushiX - (i * segmentSpacing) + stretch;
}
bodyPositions[i].y = chamushiY + lift + sinkOffset;
}
for (let i = segmentsCount - 1; i > 0; i--) {
const pos = bodyPositions[i];
ctx.fillStyle = '#7cb342';
ctx.beginPath(); ctx.arc(pos.x, pos.y, 22, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#558b2f'; ctx.lineWidth = 2; ctx.stroke();
}
const head = bodyPositions[0];
const hx = head.x; const hy = head.y - 12;
ctx.strokeStyle = '#33691e'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(hx - 10, hy - 18); ctx.quadraticCurveTo(hx - 30, hy - 40, hx - 30, hy - 50); ctx.stroke();
ctx.beginPath(); ctx.moveTo(hx + 10, hy - 18); ctx.quadraticCurveTo(hx + 30, hy - 40, hx + 30, hy - 50); ctx.stroke();
ctx.save();
ctx.translate(hx - 30, hy - 50);
ctx.rotate(-Math.PI / 10);
ctx.fillStyle = '#1b5e20';
ctx.beginPath(); ctx.ellipse(0, -15, 12, 22, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#33691e'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -40); ctx.stroke();
ctx.restore();
ctx.save();
ctx.translate(hx + 30, hy - 50);
ctx.rotate(Math.PI / 10);
ctx.fillStyle = '#1b5e20';
ctx.beginPath(); ctx.ellipse(0, -15, 12, 22, 0, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#33691e'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -40); ctx.stroke();
ctx.restore();
ctx.fillStyle = '#7cb342'; ctx.beginPath(); ctx.arc(hx, hy, 34, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#558b2f'; ctx.lineWidth = 2.5; ctx.stroke();
let cheekGrad = ctx.createRadialGradient(hx - 20, hy + 6, 2, hx - 20, hy + 6, 10);
cheekGrad.addColorStop(0, 'rgba(255, 90, 90, 0.6)'); cheekGrad.addColorStop(1, 'rgba(255, 90, 90, 0)');
ctx.fillStyle = cheekGrad; ctx.beginPath(); ctx.arc(hx - 20, hy + 6, 10, 0, Math.PI*2); ctx.fill();
let cheekGradR = ctx.createRadialGradient(hx + 20, hy + 6, 2, hx + 20, hy + 6, 10);
cheekGradR.addColorStop(0, 'rgba(255, 90, 90, 0.6)'); cheekGradR.addColorStop(1, 'rgba(255, 90, 90, 0)');
ctx.fillStyle = cheekGradR; ctx.beginPath(); ctx.arc(hx + 20, hy + 6, 10, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#222'; ctx.strokeStyle = '#222'; ctx.lineWidth = 2.5;
// ★ 救出時の「> <」目
if (gameState === "RESCUING" || gameState === "GAMEOVER") {
ctx.beginPath(); ctx.moveTo(hx - 22, hy - 10); ctx.lineTo(hx - 12, hy - 4); ctx.lineTo(hx - 22, hy + 2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(hx + 22, hy - 10); ctx.lineTo(hx + 12, hy - 4); ctx.lineTo(hx + 22, hy + 2); ctx.stroke();
// 汗マーク
ctx.fillStyle = 'rgba(100, 200, 255, 0.8)';
ctx.beginPath();
ctx.arc(hx + 25, hy - 15, 4, 0, Math.PI*2);
ctx.moveTo(hx + 25 - 4, hy - 15);
ctx.lineTo(hx + 25, hy - 25);
ctx.lineTo(hx + 25 + 4, hy - 15);
ctx.fill();
} else {
ctx.beginPath(); ctx.arc(hx - 16, hy - 6, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.moveTo(hx - 24, hy - 6); ctx.lineTo(hx - 8, hy - 6); ctx.stroke();
ctx.beginPath(); ctx.arc(hx + 16, hy - 6, 2.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.moveTo(hx + 24, hy - 6); ctx.lineTo(hx + 8, hy - 6); ctx.stroke();
}
ctx.fillStyle = '#1b5e20'; ctx.beginPath(); ctx.arc(hx, hy + 4, 11, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(hx - 5, hy + 18); ctx.quadraticCurveTo(hx, hy + 21, hx + 5, hy + 18); ctx.stroke();
if (gameState !== "GAMEOVER" && gameState !== "RESCUING" && Math.random() < 0.1) {
createSteam(hx, hy + 22);
}
updateAndDrawSteams();
ctx.fillStyle = '#bcaaa4'; ctx.fillRect(hx - 15, hy + 22, 30, 24);
ctx.strokeStyle = '#5d4037'; ctx.lineWidth = 2; ctx.strokeRect(hx - 15, hy + 22, 30, 24);
ctx.fillStyle = '#c5e1a5'; ctx.beginPath(); ctx.ellipse(hx, hy + 22, 15, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = '#2e7d32'; ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('茶', hx, hy + 39);
ctx.fillStyle = '#7cb342'; ctx.beginPath(); ctx.arc(hx - 16, hy + 32, 6, 0, Math.PI * 2); ctx.arc(hx + 16, hy + 32, 6, 0, Math.PI * 2); ctx.fill();
}
let goalReachedTime = 0;
function updateGameLogic() {
// ★ 救出モード中はここでタイマーを進めて終了させる
if (gameState === "RESCUING") {
rescueTimer++;
if (rescueTimer > 150) { // 約2.5秒後にゲームオーバー画面へ
gameState = "GAMEOVER";
showGameOver();
}
return; // 画面スクロールや当たり判定はスキップ
}
let currentPercent = Math.floor((currentSpeed / baseSpeed) * 100);
if (currentPercent >= targetSpeedPercent && !isGoalVisible) {
isGoalVisible = true;
goalX = canvas.width;
}
if (isGoalVisible) {
goalX -= currentSpeed;
landX -= currentSpeed;
waterScroll -= currentSpeed * 0.5;
for (let i = 0; i < leaves.length; i++) {
leaves[i].x -= currentSpeed;
}
if (goalX <= chamushiX + 60) {
gameState = "GOAL_REACHED";
goalReachedTime = time;
SoundEngine.stopBGM();
SoundEngine.seClear();
setTimeout(showClearScreen, 1500);
}
} else {
landX -= currentSpeed;
waterScroll -= currentSpeed * 0.5;
for (let i = 0; i < leaves.length; i++) {
leaves[i].x -= currentSpeed;
}
}
if (leaves.length > 0 && leaves[0].x < -150) {
leaves.shift();
}
const headX = bodyPositions[0].x;
let onAnyLeaf = false;
if (headX < chamushiX + 20 + landX) {
onAnyLeaf = true;
}
if (isGoalVisible && headX > goalX - 50) {
onAnyLeaf = true;
}
for (let i = 0; i < leaves.length; i++) {
let leaf = leaves[i];
if (headX >= leaf.x - leaf.type.size && headX <= leaf.x + leaf.type.size) {
onAnyLeaf = true;
if (!leaf.stepped && gameState === "PLAYING") {
leaf.stepped = true;
score += 10;
totalScore += 10;
currentSpeed += leaf.type.bonus;
scoreVal.textContent = totalScore;
speedVal.textContent = Math.floor((currentSpeed / baseSpeed) * 100) + "%";
SoundEngine.seStep();
SoundEngine.updateSpeed(currentSpeed / baseSpeed);
}
}
}
// ★ 水に落ちた時の処理を RESCUING に変更
if (!onAnyLeaf && gameState === "PLAYING") {
gameState = "RESCUING";
rescueTimer = 0;
SoundEngine.stopBGM();
SoundEngine.seSplash();
// アメンボたちの救出目標位置をセット
for (let i = 0; i < racingGerrids.length; i++) {
let g = racingGerrids[i];
g.rescueX = (g.x + waterScroll) % (canvas.width + 100) - 50;
if (g.rescueX < -50) g.rescueX += (canvas.width + 100);
g.rescueY = g.y;
}
}
}
function showGameOver() {
uiOverlay.classList.add('hidden');
dragGuide.classList.add('hidden');
msgScreen.classList.remove('hidden');
screenTitle.textContent = "アメンボたちのやさしい救出!";
screenSubTitle.textContent = "ああっ!水たまりにポチャリ...";
screenDesc.innerHTML = `薬草の道が途切れてしまいましたが、レース中のアメンボたちが<br>急いで駆けつけて助けてくれました。<br><strong style="font-size:24px; color:#ffb74d;">通算スコア: ${totalScore}点</strong><br>到達速度: ${Math.floor((currentSpeed / baseSpeed) * 100)}% / 目標 ${targetSpeedPercent}%`;
startBtn.textContent = "お礼を言ってやり直す";
}
function showClearScreen() {
gameState = "CLEAR";
uiOverlay.classList.add('hidden');
dragGuide.classList.add('hidden');
msgScreen.classList.remove('hidden');
screenTitle.textContent = "ステージクリア!";
screenSubTitle.textContent = `Stage ${currentStage} 突破!`;
let nextTarget = 300 + (currentStage) * 10;
screenDesc.innerHTML = `素晴らしい!茶虫くんは無事に対岸へたどり着きました。<br>薬草茶パワーも全開です!<br><br><strong style="font-size:24px; color:#ffb74d;">現在の通算スコア: ${totalScore}点</strong><br><br>次のステージ(Stage ${currentStage + 1})は、目標速度 <strong style="color:#aed581;">${nextTarget}%</strong> で対岸が見えてきます。`;
startBtn.textContent = "次のステージへ進む";
}
function loop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground();
for (let i = 0; i < leaves.length; i++) {
drawLeafShape(leaves[i].x, leaves[i].y, leaves[i].type, false);
}
updateAndDrawRipples();
updateAndDrawChamushi();
if (gameState === "PLAYING" || gameState === "RESCUING") {
updateGameLogic();
time += 0.02 + (gameState === "RESCUING" ? 0 : (currentSpeed * 0.003));
} else if (gameState === "GOAL_REACHED") {
time += 0.02;
} else if (gameState === "START" || gameState === "GAMEOVER" || gameState === "CLEAR") {
time += 0.02;
}
if (isDragging && currentNextHerb) {
drawLeafShape(mouseX, mouseY, currentNextHerb, false);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(mouseX, mouseY);
ctx.lineTo(mouseX, chamushiY);
ctx.stroke();
ctx.setLineDash([]);
}
requestAnimationFrame(loop);
}
initGame(true);
loop();
</script>
</body>
</html>


■ 今日の学び

今回は、いつも通りキャラクターデザインから始まったゲーム制作でした。
私の熱意が伝わったのか、AI側もいつになく気合いが入っていて、
普段は見せないようなキャラクター画像やアニメーション動画を
次々と生成してくれました。

……が、ここからが怒涛の展開でした。₍Ꙭ₎

ブログ記事を仕上げるために、
「さあ、AIとのやり取りを読み返そう」と
最初の動画付近までスクロールした、その瞬間。

画面がピタッと固まり、
次の瞬間には—— そこから先の大切なやり取りが、
跡形もなく消えていたのです。

まるで夢から覚めたみたいに、ログがすべて真っ白。
あの時の心臓のヒュッとする感じは、今思い出しても忘れられません。

幸いにも、完成したゲームコードだけは別で保存していたため、
作品そのものが消える最悪の事態は免れました。

でも、AIと一緒に試行錯誤した“対話の足跡”が消えてしまったショックは、
正直かなり大きかったです。(>x<、)

今回の件で得た最大の学びは、
「AIとの開発では、予期せぬトラブルでデータが消えることも普通に起こり得る」 という、とてもリアルな教訓でした。

原因を一つに断定することはできませんが、
画像や動画の処理負荷、セッションの限界など、
いろんな要素が重なったのだと思います。

大切にしていた記録が消えたのは確かに悲しい出来事でした。
でも、この経験も“成長の一歩”だと信じています。

私の座右の銘は、とてもシンプルに「進歩」。
どんな壁にぶつかっても、それをバネにして昨日より今日、
今日より明日へと進み続けること。

それこそが、この「100のゲーム実験室」を動かす原動力なんだと、
改めて実感しました。

今回のアクシデントと、そこからの奇跡の復元劇も含めて、
97個目のゲームは忘れられない作品になりました!

読んでくれたあなたにも、
少しでもこの冒険のドキドキが伝わっていたら嬉しいです。

■ 次回は、手作り新聞バッグから生まれるゲームです。

新聞で作った手作りバッグって、おしゃれですよね。

記事の文字や写真、イラストがそのまま模様になって、
世界にひとつだけの“紙のアート作品”みたいな存在感があります。

同じ新聞でも、折り方や切り取り方で雰囲気がガラッと変わる。
その偶然性もまた、手作り新聞バッグの魅力です。

そんな新聞バッグをテーマにした、 ちょっとユニークで、
ちょっとアートなゲームを作ります。

次回もお楽しみに!ヽ(*^v^*)ノ

▶ 次の実験はこちら  
(新聞バックのおしゃれレイアウト)  
「新聞バッグ・ギャラリー」



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

~このブログについて~

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

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー


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

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

続きを読む