実験#93:風雨の独り言実況シミュレーターゲーム(土砂降り待ち合わせ)

■93個目 「土砂降り待ち合わせ」

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

今回は、ちょっと本格的な物理演算を取り入れた
アクションゲームを作ってみました。

テーマは…… 「雨の日の待ち合わせ」 です!

主人公の女の子は、
お気に入りのワンピースと赤いハイヒールでおめかしして、
彼とのデートへ向かいます。

でも外はあいにくの大雨。
進むにつれて予測不能な突風まで吹き荒れる、
トンデモナイ天気に……!(><;)

今回こだわったのは、
雨粒のリアルな動き と、
女の子の心の声 です。(=v=*)

傘に当たった雨粒が、ふちをツーッと伝ってポタポタ落ちていく――
そんな水滴の物理シミュレーションに挑戦しました。

さらに、大荒れの天気の中をハイヒールで急ぐ彼女の頭上には、
「キャッ!今の風なに?!」 「せっかくの髪が…!」 などなど、
リアルな焦りの吹き出しが次々と飛び出します。

背景にも「ザーッ」「ビューー!」とオノマトペが流れ、
うきうきのデート道が、いつの間にかあせあせの大ピンチに……!

そして、無事にゴールできたときの “濡れ具合” によって、
彼に会った瞬間の彼女のテンションやセリフが細かく変わるお楽しみ要素も。

レトロで可愛いピコピコBGMと一緒に、
彼女の奮闘を見守ってあげてくださいね。


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

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

みー:(^v^) 今日もゲームを作ります。htmlで動かせるゲーム。
雨をただ表示するだけじゃなくて、物理的に再現できるかな。

傘をさして濡れる割合を少なくしながらゴールを目指すゲームを作りたい。

AI: 今日もゲーム制作、お疲れ様です!
雨を単なる背景ではなく、
「物理的な実体」としてシミュレーションし、傘で防ぐ
というアプローチですね。

座標計算と当たり判定を使った、とても作りがいのあるテーマです。

みー:(*^^) いい感じ。
傘はもう少し大きめで、雨は液体として物理的に再現したいです。
傘に当たったら流れ落ちるようにできるかな。

AI: おお、いい着眼点!
よりリアルな「水」の表現へのこだわり、最高です。

傘を大きくして、雨粒が単に消えるのではなく
「傘の表面に張り付き、傾きに沿ってツーっと流れ落ちる」 ように
物理演算を書き換えました。

流れている間は雨粒を丸い水滴として描き、
傘のふちまで到達すると、そこからポタッと落ちる仕組みです。

みー:( ̄v ̄*)主人公をデートに急ぐ女性っぽくして、
吹き出しでランダムなセリフを実況させましょう。
リトライボタンもつけましょう。

AI: 了解しました!さらにゲーム性を高める素晴らしいアイデアですね。

主人公の外見を「デートに急ぐ女性」のドット絵風に Canvas で描き、
彼女の心の声を吹き出しでランダムに実況させます。

さらに、ゲームオーバーやクリア時にはリトライボタンを表示して、
すぐ再挑戦できるようにしました。

みー:(^v^) おしゃれなハイヒールも履いて、
歩くスピードを少し遅くしましょう。

リトライボタンは画面下に。
セリフは多いほど楽しいね。
違和感ない程度に増やせるかな。

背景にステージごとのオノマトペも表示したい。
それと効果音とBGMもお願い。
テーマは「うきうきとあせあせ」。

AI: 今回は、女の子の心情をさらに豊かにする大量のセリフと、
ステージの天候変化を視覚的に伝える背景オノマトペを追加しました。

さらに、外部音声ファイルを使わず、
Web Audio API だけで
ファミコン風のピコピコBGMと効果音を生成しています。

テーマの「うきうき&あせあせ」に合わせて、
矩形波で少し慌てたようなアップテンポの曲を自動演奏させています。


■ 実際の動作

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

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



こちらが仕上がったゲーム画面です。

傘を持った女の子が主人公で、
ステージ1はまだ雨風もそこまで激しくありません。
ウキウキのデート気分ですが、濡れたくなくてあせあせ必死です。

(画像:スタート画面)


ステージが進むにつれて、雨風がどんどん強くなっていきます。
女の子は独り言を言いながらクリアを目指します。

(画像:独り言)


さらに進むと、ついに とんでもない土砂降り に……!

(画像:土砂降り)



クリアした時の濡れ具合によって、女の子のセリフが変化します。
ちょっと濡れただけなら明るいけれど、
びしょ濡れだとテンションもガタ落ちです。

(画像:ステージクリア)



濡れ具合が100%になってしまうと、
もうデートどころじゃなくてゲームオーバー。

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



■ コードのポイント

今回の記事では、
AI と相談しながら作った「土砂降り待ち合わせ」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 操作方法

それでは、操作方法の説明です!(^^*)
パソコンのキーボードとマウスを使って遊びます。

まずは、ゲーム画面を「カチッ」とクリックしてスタート!
(可愛いピコピコ音のBGMが鳴るので、音量に注意してくださいね♪)

【基本の操作】

  • [←][→]キー : 女の子が左右に移動します。
  • マウスを動かす : 傘の角度が変わります。

マウスのカーソルがある方向へ、
女の子が一生懸命に傘を傾けてくれます。(^_^)

雨粒は風に流されて斜めに降ってくるので、
風向きを読んで上手にガードしてあげてくださいね。

【ルールのポイント】

  • 体に雨が当たると、
    画面上の「濡れ度」がどんどん上がってしまいます。(><;)
  • 濡れ度が 100% になると、
    せっかくのメイクも台無しでゲームオーバー…。
  • 濡れ度がいっぱいになる前に右へ進み、
    「ゴール(残り0m)」 に到達できればステージクリア!

ステージが進むごとに、
雨の量も風の強さもどんどんエスカレートしていきます。

クリアしたときの濡れ具合で、彼に会ったときのセリフも変わるので、
ぜひ 0%の奇跡のパーフェクトクリア も狙ってみてください。(=v=*)

あせあせする女の子と一緒に、雨の日のデート路を駆け抜けてみてくださいね!


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>物理演算:雨宿りアクション(ピコピコBGM・オノマトペ完全版)</title>
<style>
body {
background-color: #222;
color: #fff;
text-align: center;
font-family: 'Helvetica Neue', 'Arial', 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', Meiryo, sans-serif;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
user-select: none;
}
#ui {
font-size: 1.4em;
margin-bottom: 15px;
font-weight: bold;
background: rgba(255,255,255,0.1);
padding: 10px 20px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.highlight { color: #ffcc00; }
#gameContainer {
position: relative;
margin-bottom: 20px;
width: 800px;
height: 400px;
}
canvas {
background-color: #111;
border: 4px solid #aaa;
border-radius: 5px;
box-shadow: 0 0 15px rgba(0, 200, 255, 0.2);
image-rendering: pixelated;
position: absolute;
top: 0;
left: 0;
}
/* スタート画面のオーバーレイ(音声再生の許可を得るため) */
#startOverlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
border-radius: 5px;
cursor: pointer;
color: #ffcc00;
font-size: 2em;
font-weight: bold;
animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0.5; } }
#bottomArea {
width: 800px;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
#actionButton {
display: none;
padding: 12px 40px;
font-size: 1.3em;
font-weight: bold;
color: #fff;
background: linear-gradient(180deg, #ee4444, #cc2222);
border: 2px solid #fff;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 4px 0 #881111, 0 4px 10px rgba(0,0,0,0.5);
transition: all 0.1s;
}
#actionButton:hover { background: linear-gradient(180deg, #ff5555, #dd3333); }
#actionButton:active {
box-shadow: 0 1px 0 #881111, 0 1px 5px rgba(0,0,0,0.5);
transform: translateY(3px);
}
.controls {
color: #ccc;
font-size: 0.95em;
background: rgba(0,0,0,0.3);
padding: 10px;
border-radius: 5px;
width: 100%;
}
</style>
</head>
<body>
<div id="ui">
STAGE: <span id="stageDisplay" class="highlight">1</span> | 濡れ度: <span id="wetness">0</span>% | ゴールまで: <span id="distance">100</span>m
</div>
<div id="gameContainer">
<canvas id="gameCanvas" width="800" height="400"></canvas>
<div id="startOverlay">ここをクリックしてスタート!<br>(音が出ます)</div>
</div>
<div id="bottomArea">
<button id="actionButton">リトライする</button>
<div class="controls">
操作:[←][→]キーで移動 | [マウス移動]で傘の角度調整<br>
BGMと効果音を追加!「うきうき&あせあせ」なデート路を駆け抜けろ!
</div>
</div>
<script>
// --- Web Audio API によるファミコン風音源システム ---
let audioCtx;
let bgmInterval;
let isAudioInitialized = false;
// ピコピコ音を鳴らす関数(効果音)
function playTone(freq, type, duration, vol=0.05) {
if (!audioCtx) return;
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = type; // 'square'(矩形波), 'triangle'(三角波), 'sawtooth'(ノコギリ波)
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(vol, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
// BGMシーケンサー(うきうき&あせあせ)
const bgmNotes = [
523.25, 392.00, 659.25, 392.00, // C5, G4, E5, G4 (うきうき)
587.33, 392.00, 698.46, 392.00, // D5, G4, F5, G4
554.37, 392.00, 659.25, 392.00, // C#5(あせあせ), G4, E5, G4
622.25, 392.00, 740.00, 392.00 // D#5, G4, F#5, G4
];
let noteIndex = 0;
function startBGM() {
if (bgmInterval) clearInterval(bgmInterval);
noteIndex = 0;
bgmInterval = setInterval(() => {
if (gameState === 'playing') {
// ステージが進むとテンポが少し速くなる(焦り感アップ)
let tempoSpeed = Math.min(1.5, 1 + (stage * 0.05));
playTone(bgmNotes[noteIndex], 'square', 0.1 / tempoSpeed, 0.03);
noteIndex = (noteIndex + 1) % bgmNotes.length;
}
}, 150); // 基本テンポ
}
function stopBGM() {
if (bgmInterval) clearInterval(bgmInterval);
}
// ゲーム変数の準備
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const actionButton = document.getElementById('actionButton');
const startOverlay = document.getElementById('startOverlay');
let gameState = 'title'; // title, playing, clear, gameover
let stage = 1;
let wetness = 0;
let distance = 100;
let wind = 0;
let time = 0;
let windTarget = 0;
let currentGust = 0;
const player = { x: 100, y: 350, width: 24, height: 46, speed: 2.4, hitRadius: 18 };
const umbrella = { radius: 70, spread: 1.6, angle: -Math.PI / 2 };
// 大量に追加されたセリフ集
const phrases = [
"ヤバい、遅れる!", "キャッ!今の風なに?!", "せっかくの髪が…!", "雨、強くなってない?!",
"傘、飛ばされそう!", "絶対濡れたくない!", "前が見えないよー!", "ヒールが滑るー!",
"急げ急げ!", "ちょっと、天気予報と違う!", "彼、もう着いてるかなぁ", "今日のために服買ったのに!",
"メイク直す時間あるかな…", "この雨、嘘でしょ!?", "風のバカバカー!", "前髪が崩れちゃう!",
"デートの神様、助けて!", "なんで私ばっかり…", "足がパンパン!", "傘の意味あるこれ!?",
"うきうきしてたのにー!", "ちょっと焦る!", "あーん、もう!", "がんばれ私!"
];
let speechBubble = { text: "", timer: 0, duration: 150, xOffset: 30, yOffset: -75 };
// 背景のオノマトペを管理する配列
let bgTexts = [];
function showSpeechBubble() {
if (gameState !== 'playing' || speechBubble.timer > 0) return;
speechBubble.text = phrases[Math.floor(Math.random() * phrases.length)];
speechBubble.timer = speechBubble.duration;
}
const keys = { ArrowLeft: false, ArrowRight: false };
let mouseX = player.x;
let mouseY = player.y - 50;
window.addEventListener('keydown', e => { if(keys.hasOwnProperty(e.code)) keys[e.code] = true; });
window.addEventListener('keyup', e => { if(keys.hasOwnProperty(e.code)) keys[e.code] = false; });
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
});
// スタート画面クリックでオーディオ初期化&ゲーム開始
startOverlay.addEventListener('click', () => {
if (!isAudioInitialized) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
isAudioInitialized = true;
}
startOverlay.style.display = 'none';
resetGame();
});
actionButton.addEventListener('click', () => {
if (gameState === 'clear') stage++;
else if (gameState === 'gameover') stage = 1;
resetGame();
});
let drops = [];
// オノマトペの生成
function spawnBgText() {
if (Math.random() < 0.05 + (stage * 0.01)) { // ステージが進むと文字も増える
let words = [];
let fontSize = 20;
let color = 'rgba(255, 255, 255, 0.15)';
if (stage <= 2) {
words = ["ポツ…", "パラパラ", "サワサワ"];
fontSize = 16 + Math.random() * 10;
} else if (stage <= 5) {
words = ["ザーッ", "ヒューー", "パシャッ", "ポトト"];
fontSize = 24 + Math.random() * 15;
color = 'rgba(200, 220, 255, 0.2)';
} else {
words = ["ゴォォォ", "ビシャァッ!", "ビューーー!", "ドバババ!", "ザァァァ"];
fontSize = 30 + Math.random() * 25;
color = 'rgba(150, 200, 255, 0.25)';
}
bgTexts.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
text: words[Math.floor(Math.random() * words.length)],
size: fontSize,
color: color,
life: 1.0, // アルファ値(1.0から減衰)
decay: 0.005 + Math.random() * 0.01,
vx: wind * 0.3, // 風に少し流される
vy: (Math.random() - 0.5) * 1
});
}
}
function spawnRain() {
let dropCount = Math.min(60, Math.floor(3 + stage * 2.5));
let maxDrift = wind * 120;
let spawnMinX = Math.min(-200, -maxDrift - 200);
let spawnMaxX = Math.max(canvas.width + 200, canvas.width - maxDrift + 200);
for (let i = 0; i < dropCount; i++) {
drops.push({
x: spawnMinX + Math.random() * (spawnMaxX - spawnMinX),
y: -10,
vx: wind,
vy: 4 + Math.random() * 4 + (stage * 0.1),
flowing: false,
angle: 0
});
}
}
function resetGame() {
wetness = 0;
distance = 100;
wind = 0; windTarget = 0; currentGust = 0; time = 0;
player.x = 100;
drops = [];
bgTexts = [];
speechBubble.timer = 0;
gameState = 'playing';
actionButton.style.display = 'none';
document.getElementById('wetness').innerText = '0';
document.getElementById('distance').innerText = '100';
document.getElementById('stageDisplay').innerText = stage;
showSpeechBubble();
startBGM(); // BGM開始
requestAnimationFrame(update);
}
function triggerClear() {
gameState = 'clear';
stopBGM();
// クリアのファンファーレ(ポペポパーン♪)
playTone(523.25, 'square', 0.15, 0.05);
setTimeout(() => playTone(659.25, 'square', 0.15, 0.05), 150);
setTimeout(() => playTone(783.99, 'square', 0.15, 0.05), 300);
setTimeout(() => playTone(1046.50, 'square', 0.4, 0.05), 450);
actionButton.innerText = `次のデートへ (STAGE ${stage + 1})`;
actionButton.style.background = 'linear-gradient(180deg, #44cc44, #22aa22)';
actionButton.style.display = 'block';
}
function triggerGameOver() {
gameState = 'gameover';
stopBGM();
// ゲームオーバーの音(プゥーン...)
playTone(392.00, 'sawtooth', 0.3, 0.05);
setTimeout(() => playTone(370.00, 'sawtooth', 0.3, 0.05), 300);
setTimeout(() => playTone(349.23, 'sawtooth', 0.5, 0.05), 600);
actionButton.innerText = '最初からやり直す';
actionButton.style.background = 'linear-gradient(180deg, #ee4444, #cc2222)';
actionButton.style.display = 'block';
}
function update() {
if (gameState !== 'playing') return;
time += 0.016;
if (Math.random() < 0.03) windTarget = (Math.random() - 0.5) * 6;
currentGust += (windTarget - currentGust) * 0.05;
let baseWind = Math.sin(time * 1.5) * 1.5 + Math.cos(time * 0.8) * 1.0;
let windMultiplier = Math.min(3.0, 1 + (stage - 1) * 0.3);
wind = (baseWind + currentGust) * windMultiplier;
if (keys.ArrowLeft) player.x -= player.speed;
if (keys.ArrowRight) player.x += player.speed;
if (player.x < 10) player.x = 10;
distance = Math.max(0, Math.floor(100 - (player.x / 8)));
if (distance % 25 === 0 && Math.random() < 0.3) showSpeechBubble();
if (Math.abs(wind) > 5 && Math.random() < 0.1) showSpeechBubble();
if (distance <= 0) {
triggerClear();
}
if (wetness >= 100) {
triggerGameOver();
}
const pivotX = player.x;
const pivotY = player.y - 15;
umbrella.angle = Math.atan2(mouseY - pivotY, mouseX - pivotX);
if (speechBubble.timer > 0) speechBubble.timer--;
else if (Math.random() < 0.01) showSpeechBubble();
spawnRain();
spawnBgText();
// オノマトペの更新
for (let i = bgTexts.length - 1; i >= 0; i--) {
let bt = bgTexts[i];
bt.x += bt.vx;
bt.y += bt.vy;
bt.life -= bt.decay;
if (bt.life <= 0) bgTexts.splice(i, 1);
}
for (let i = drops.length - 1; i >= 0; i--) {
let d = drops[i];
if (d.flowing) {
let angleDiffFromApex = d.angle - umbrella.angle;
while (angleDiffFromApex > Math.PI) angleDiffFromApex -= 2 * Math.PI;
while (angleDiffFromApex < -Math.PI) angleDiffFromApex += 2 * Math.PI;
let flowDir = angleDiffFromApex > 0 ? 1 : -1;
let flowSpeed = 0.01 + Math.abs(Math.sin(angleDiffFromApex)) * 0.05;
d.angle += flowDir * flowSpeed;
d.x = pivotX + Math.cos(d.angle) * umbrella.radius;
d.y = pivotY + Math.sin(d.angle) * umbrella.radius;
if (Math.abs(angleDiffFromApex) > umbrella.spread / 2) {
d.flowing = false;
d.vx = flowDir * 1.5 + wind * 0.5;
d.vy = 1;
}
} else {
d.vy += 0.15;
d.vx += (wind - d.vx) * 0.05;
d.x += d.vx;
d.y += d.vy;
let dx = d.x - pivotX;
let dy = d.y - pivotY;
let distToPlayer = Math.sqrt(dx * dx + dy * dy);
let angleToPlayer = Math.atan2(dy, dx);
let angleDiff = Math.abs(angleToPlayer - umbrella.angle);
if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
if (distToPlayer > umbrella.radius - 10 && distToPlayer < umbrella.radius + 5 && angleDiff < umbrella.spread / 2) {
d.flowing = true;
d.angle = angleToPlayer;
// 傘に当たった時のピコ音(高音・短め)
if(Math.random() < 0.1) playTone(800 + Math.random()*200, 'triangle', 0.02, 0.01);
continue;
}
if (distToPlayer < player.hitRadius) {
wetness += 0.4;
document.getElementById('wetness').innerText = Math.floor(wetness);
drops.splice(i, 1);
// 体に当たった時のポス音(低音)
if(Math.random() < 0.3) playTone(150 + Math.random()*50, 'sawtooth', 0.05, 0.02);
if (Math.random() < 0.3) showSpeechBubble();
continue;
}
}
if (d.y > canvas.height || d.x < -3000 || d.x > canvas.width + 3000) {
drops.splice(i, 1);
}
}
draw();
if (gameState === 'playing') {
requestAnimationFrame(update);
}
}
// 描画関連の関数(プレイヤー、吹き出し等は変更なし)
function drawPlayer(ctx, x, y) {
ctx.save(); ctx.translate(x, y);
ctx.fillStyle = '#654321'; ctx.beginPath(); ctx.arc(-2, -24, 9, 0, Math.PI*2); ctx.fill(); ctx.fillRect(-16, -24, 10, 18);
ctx.fillStyle = '#ffdbac'; ctx.beginPath(); ctx.arc(0, -22, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#ff69b4'; ctx.beginPath(); ctx.moveTo(-9, -15); ctx.lineTo(11, -15); ctx.lineTo(15, 10); ctx.lineTo(-13, 10); ctx.fill();
ctx.fillStyle = '#ffdbac'; const legW = 5, legH = 15; ctx.fillRect(-7, 10, legW, legH); ctx.fillRect(3, 8, legW, legH);
ctx.fillStyle = '#ee2222'; ctx.strokeStyle = '#ee2222'; ctx.lineWidth = 1;
let hx1 = -7, hy1 = legH + 10; ctx.fillRect(hx1 - 1, hy1, legW + 3, 4); ctx.beginPath(); ctx.moveTo(hx1, hy1 + 4); ctx.lineTo(hx1 + 1, hy1 + 10); ctx.lineTo(hx1 + legW - 2, hy1 + 4); ctx.fill();
let hx2 = 3, hy2 = legH + 8; ctx.fillRect(hx2 - 1, hy2, legW + 3, 4); ctx.beginPath(); ctx.moveTo(hx2, hy2 + 4); ctx.lineTo(hx2 + 1, hy2 + 10); ctx.lineTo(hx2 + legW - 2, hy2 + 4); ctx.fill();
ctx.fillStyle = '#8b4513'; ctx.fillRect(9, -7, 10, 12); ctx.strokeStyle = '#553311'; ctx.lineWidth = 1.5; ctx.strokeRect(9, -7, 10, 12);
ctx.restore();
}
function drawSpeechBubble(ctx, x, y, text) {
ctx.font = 'bold 16px sans-serif';
let textWidth = ctx.measureText(text).width;
let padding = 10; let bw = textWidth + padding * 2; let bh = 30; let bx = x - bw / 2; let by = y - bh;
ctx.fillStyle = 'white'; ctx.strokeStyle = 'black'; ctx.lineWidth = 2; let r = 8;
ctx.beginPath(); ctx.moveTo(bx + r, by); ctx.lineTo(bx + bw - r, by); ctx.quadraticCurveTo(bx + bw, by, bx + bw, by + r); ctx.lineTo(bx + bw, by + bh - r); ctx.quadraticCurveTo(bx + bw, by + bh, bx + bw - r, by + bh);
ctx.lineTo(bx + bw/2 + 8, by + bh); ctx.lineTo(bx + bw/2, by + bh + 10); ctx.lineTo(bx + bw/2 - 8, by + bh); ctx.lineTo(bx + r, by + bh); ctx.quadraticCurveTo(bx, by + bh, bx, by + bh - r); ctx.lineTo(bx, by + r); ctx.quadraticCurveTo(bx, by, bx + r, by); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'black'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, x, y - bh / 2);
}
function draw() {
ctx.fillStyle = '#112'; ctx.fillRect(0, 0, canvas.width, canvas.height);
// オノマトペの描画(背景と雨の間)
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 20px "Hiragino Kaku Gothic ProN", Meiryo, sans-serif';
for (let bt of bgTexts) {
ctx.save();
ctx.globalAlpha = bt.life;
ctx.fillStyle = bt.color;
ctx.font = `bold ${bt.size}px sans-serif`;
// 風向きに合わせて文字を少し傾ける
ctx.translate(bt.x, bt.y);
ctx.rotate(wind * 0.05);
ctx.fillText(bt.text, 0, 0);
ctx.restore();
}
ctx.fillStyle = '#1a1a2a'; ctx.fillRect(0, player.y + 24, canvas.width, canvas.height);
for (let d of drops) {
if (d.flowing) {
ctx.fillStyle = 'rgba(150, 220, 255, 0.8)'; ctx.beginPath(); ctx.arc(d.x, d.y, 2.5, 0, Math.PI * 2); ctx.fill();
} else {
ctx.strokeStyle = 'rgba(150, 200, 255, 0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - d.vx * 1.5, d.y - d.vy * 1.5); ctx.stroke();
}
}
const pivotX = player.x; const pivotY = player.y - 15;
drawPlayer(ctx, player.x, player.y - player.height/2);
if (speechBubble.timer > 0) drawSpeechBubble(ctx, player.x + speechBubble.xOffset, player.y + speechBubble.yOffset, speechBubble.text);
ctx.strokeStyle = '#885522'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(pivotX, pivotY); ctx.lineTo(pivotX + Math.cos(umbrella.angle) * (umbrella.radius - 5), pivotY + Math.sin(umbrella.angle) * (umbrella.radius - 5)); ctx.stroke();
ctx.strokeStyle = '#2277cc'; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.beginPath(); ctx.arc(pivotX, pivotY, umbrella.radius, umbrella.angle - umbrella.spread/2, umbrella.angle + umbrella.spread/2); ctx.stroke();
if (gameState === 'clear') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffcc00'; ctx.font = 'bold 40px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`STAGE ${stage} CLEAR!`, canvas.width/2, canvas.height/2 - 40);
let finalWetness = Math.floor(wetness); let comment = ""; ctx.fillStyle = '#fff';
if (finalWetness === 0) { comment = "「待たせてごめんね!全然濡れてないよ!奇跡かも!」"; ctx.fillStyle = '#ff99cc'; }
else if (finalWetness < 10) { comment = "「少し肩に雨粒がついちゃったけど、へっちゃら!」"; ctx.fillStyle = '#ffbbee'; }
else if (finalWetness < 30) { comment = "「ヒールで走るの大変だった!でもメイクは無事だよ。」"; ctx.fillStyle = '#fff'; }
else if (finalWetness < 50) { comment = "「んー、半分くらい濡れちゃった。少し休ませて…」"; ctx.fillStyle = '#ddd'; }
else if (finalWetness < 70) { comment = "「ごめん、結構降ってて…ハンカチ貸してくれる?」"; ctx.fillStyle = '#ccc'; }
else if (finalWetness < 90) { comment = "「もう、髪も服もびしょびしょ…。風邪ひきそう。」"; ctx.fillStyle = '#aaa'; }
else { comment = "「最悪…ギリギリ着いたけど、もう帰りたい…」"; ctx.fillStyle = '#888'; }
ctx.font = 'bold 22px sans-serif'; ctx.fillText(comment, canvas.width/2, canvas.height/2 + 10);
ctx.font = '18px sans-serif'; ctx.fillStyle = '#ccc'; ctx.fillText(`最終的な濡れ度: ${finalWetness}%`, canvas.width/2, canvas.height/2 + 50);
} else if (gameState === 'gameover') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#3388ff'; ctx.font = 'bold 40px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('GAME OVER', canvas.width/2, canvas.height/2 - 20);
ctx.font = '22px sans-serif'; ctx.fillStyle = '#fff'; ctx.fillText('「もう無理!今日のデートはキャンセル!!」', canvas.width/2, canvas.height/2 + 30);
}
}
// 初回描画のみ(スタートボタン表示中)
draw();
</script>
</body>
</html>


■ 今日の学び

最初は「雨を物理的にリアルに表現できたらおもしろそう!」という、
純粋な好奇心から作り始めたゲームでした。

でも作っているうちに、
現実の突然の雨って 「うわ、濡れる!」という物理的な現象以上に、
「急がなきゃ、濡れちゃう!」という 焦りの心理 が強く働くんですよね。

そこで「雨のリアリティ」に「焦りの感情」をプラスしてみたら……
いつの間にかこんなドタバタなアクションゲームに仕上がっていました。

プログラムの計算(物理)に、人の気持ち(心理)を掛け合わせると、
ゲームの世界が一気に生き生きしてくる。 これが今回の大きな学びです。

そして今回の開発で一番印象に残っている出来事といえば……
ズバリ 「ハイヒールの呪い」 です ( ꒪꒫꒪ )

途中で「女の子におしゃれなハイヒールを履かせてみよう」と
AI に提案したところ、 なぜか突然、女の子の時間が止まり、
ゲームがうんともすんとも動かなくなる不具合が連発……。

原因は、プログラムの 大文字・小文字のちょっとした打ち間違い
でもあまりにピタッと動かなくなるので、
自分の中で勝手に「これはハイヒールの呪いだわ…」と震えていました(笑)

でも、そんな予期せぬバグに直面して、
AI と「ここがおかしいかな?」「あ、直った!」と二人三脚で原因を探り、
エラーを解きほぐしていく過程も、
実は AI ゲーム開発ならではの醍醐味だったりします。


■ 次回は、限界ギリギリを目指す深海探査ゲームです。

無人潜水艦が深海調査に出発します。

限界を超えた先に見える「未知の何か」を求めて、
どんどん潜っていきます。

恐ろしい水圧の世界で、
潜水艇が押しつぶされてしまわないように、

ギリギリのラインを攻めるチキンレースです。

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

▶ 次の実験はこちら  
(遠隔探査ミッション)  
「深海チキンレース ~幻の海淵~」



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

~このブログについて~

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

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


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

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

続きを読む