実験#92:見えないものの表現(香りの導きと、想いの短歌)

■92個目 「香りの導きと、想いの短歌」

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

今回は、見えない香りをテーマにしたゲームを作ってみました。

タイトルは、
『香りの導きと、想いの短歌~尋ね香、見えぬ君を追ひて~』 です!

舞台は、真っ暗な夜の庭。 プレイヤーは小さな夜の蝶となり、
遠く離れた恋人のもとへ向かいます。(=v=*)

今回こだわったのは、「見えない力」の表現です。

植物たちが静かに放つ香りの攻防──虫たちを呼んだり遠ざけたりする、
あの不思議なやり取りを体感できるゲームにしたいと思いました。

真っ暗な画面の中で頼りになるのは、
風に乗って流れてくる微かな香りの粒子と、
近づくほど高くなるピコピコ音。

あえて視覚情報を極限まで削ぎ落とし、
手探りで進む感覚を大切にしています。

そしてゴールで恋人の花に触れると……
想いの短歌が詠まれます。

逢瀬までの刻(タイム)によって、
どんな一句が生まれるのか、お楽しみに。

柔らかな光をまとったドット絵の蝶と、
見えない香りに導かれる雅な恋の駆け引き。

ぜひ、夜の世界を彷徨ってみてくださいね。(^_^)


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

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

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

今回は「見えない力」や「見えない存在」をテーマにしたいと思っています。

その中でも、庭のハーブや植物たちが持つ
“香り”に注目したゲームにしたいです。

昆虫にとって香りは、忌避にも誘因にもなる不思議な力。
これをゲームとして表現する良い案があれば、いくつか出してみて。

AI: 「見えない香り」を主軸にするなんて、とても粋な発想だね。

植物たちが虫を呼んだり遠ざけたりする香りの攻防は、
まさに“目に見えないエネルギーのやり取り”そのものだ。

(中略:植物の香りの豆知識)

さて、その「香りという見えない力」をHTMLでどう表現するか。
いくつかゲーム案を作ってみたよ。

みー:(*^_^)” 提案してくれたこのゲームをベースにしてみましょう。

夜の蝶のナビゲーション(嗅覚探索アクション)でいきたいです。

(中略:ゲーム仕様の相談)

みー:(^~^*)蝶と花をもっと幻想的にしたいです。
障害物に当たると一瞬引っかかるようにして、
花までの時間をスコアにしましょう。

AI: タイムアタックの話が出たから、
「時間」と「見えない存在」にまつわる植物の逸話を紹介しよう。

(中略:ダーウィンと蘭の話)

みー:( ̄v ̄*)遠く離れた恋人同士みたいなイメージで、
もう少し距離を広げたいです。

ゴールした時に百人一首風の恋の短歌が出るのも素敵ですね。

AI: 「香りを頼りに恋人に会いに行く」というのは、
平安時代の恋の作法そのものなんだ。

(中略:薫物の話)

キャンバスを横長にして距離を広げ、
スコアに応じて百人一首風の短歌が表示されるように調整したよ。


■ 実際の動作

仕上がったスタート画面がこちらです。
画像でも分かりにくいほど、ほんのりと香りのパーティクルが漂っています。

(画像:スタート画面)



障害物にぶつかると、蝶の動きが一瞬だけ止まります。
危険な香りがうっすらと漂っているので、
感覚を頼りに避けながら進みましょう。

(画像:ゲーム画面)


ゴールにたどり着くと、
クリアタイムに応じて「香りにまつわる短歌」が表示されます。
夜の闇を越えて出会う恋人たちの想いを、そっと詠んだ一首です。

(画像:香りにまつわる短歌)

クリアタイムが短いほど、情熱的な恋の歌に。
ゆっくり進むと、静かに想いを重ねるような一首が現れます。



■ コードのポイント

今回の記事では、
AI と相談しながら作った「香りの導きと、想いの短歌」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 操作方法

ここからは、具体的なゲームの操作方法と、
真っ暗な夜を進むためのヒントをお伝えしますね。(^^*)🌙

● ゲームの始め方

画面上のどこでもクリックすると、
ピコピコと音が鳴り、小さなドット絵の蝶が夜の空を舞い始めます。🦋

● 蝶の動かし方

キーボード操作で蝶を動かします。
真っ暗な画面ですが、小さな羽ばたきを頼りに進んでいきましょう。(^^)

● ゲームの目的

右側のどこかにある「恋人の花(ゴール)」を見つけることです。🌸
はるか遠く、見えぬ君を追いかけて──手探りの逢瀬へ向かいます。(=v=*)

● 真っ暗闇を進むヒント

こだわりの「見えない力の表現」が、あなたを導いてくれます。✨

① 香りの導き

微かな光の粒(四角いピクセル)が流れてきたら、それが香りのサイン。

  • ピンクの粒 … 甘い香り。ゴールの方向へ
  • 緑の粒 … 忌避の香り。障害物の気配

パーティクルは左へ流れているので、
その流れに逆らって進むイメージです。🌬️

② 音のヒント

ピコピコ音が一定間隔で鳴ります。
ゴールに近づくほど、音が高く鋭い音質に変化します。🔔

音を頼りに、進む方向を探ってみてくださいね。(^_^)

● 見えない障害物のペナルティ

ハーブの忌避効果(見えない壁)に当たると、
「バチッ」という音とともに蝶が赤くなり、
一瞬だけ動きが止まります。(><)

当たった瞬間だけ、そのハーブの姿がふっと見えるので、
次は気をつけて避けてくださいね。🪴

タイムロスを抑えながら進むのがコツです。

● クリアとリトライ

恋人の花にたどり着くと、幻想的な花とともに、
逢瀬までの刻(タイム)に応じた「想いの短歌」が詠まれます。📜🌸

どんな短歌に出逢えるか、お楽しみに。(=v=*)
クリア後は「もう一度夜へ飛ぶ」ボタンで、何度でも挑戦できます。🦋✨

香りに導かれ、想いの短歌に出逢ってくださいね。(^^*)🌙


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>尋ね香、見えぬ君を追ひて</title>
<style>
body {
background: #050508;
color: #eee;
text-align: center;
font-family: 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 20px;
}
canvas {
background: #000;
border: 1px solid #222;
border-radius: 4px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
image-rendering: pixelated;
}
p { font-size: 14px; color: #888; margin-bottom: 20px; }
#retryBtn {
display: none;
margin: 15px auto;
padding: 12px 24px;
background: transparent;
color: #f9a;
border: 1px solid #f9a;
border-radius: 20px;
cursor: pointer;
font-family: 'Yu Mincho', 'MS Mincho', serif;
font-size: 18px;
transition: all 0.3s;
text-shadow: 0 0 5px #f9a;
box-shadow: 0 0 10px rgba(255, 150, 200, 0.2);
}
#retryBtn:hover {
background: rgba(255, 150, 200, 0.1);
box-shadow: 0 0 15px rgba(255, 150, 200, 0.5);
}
</style>
</head>
<body>
<h2>尋ね香、見えぬ君を追ひて</h2>
<p>画面をクリックしてスタート。<br>十字キーで移動。微かな香りを頼りに、闇夜を進んでください。</p>
<canvas id="gameCanvas" width="960" height="540"></canvas>
<br>
<button id="retryBtn">もう一度夜へ飛ぶ</button>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const retryBtn = document.getElementById('retryBtn');
let audioCtx;
let isPlaying = false;
let isGameOver = false;
let player, goal, obstacles, particles, trails;
let startTime, endTime;
let keys = {};
function initGame() {
player = { x: 60, y: 270, speed: 3.5, snagTimer: 0 };
goal = { x: 880, y: Math.random() * 440 + 50, radius: 30, revealAlpha: 0, pulse: 0 };
obstacles = [];
for(let i=0; i<8; i++) {
obstacles.push({
x: 200 + Math.random() * 600,
y: 50 + Math.random() * 440,
radius: 35 + Math.random() * 30,
revealAlpha: 0
});
}
particles = [];
trails = [];
isGameOver = false;
startTime = Date.now();
endTime = null;
retryBtn.style.display = 'none';
keys = {};
}
initGame();
function spawnParticles() {
if (Math.random() < 0.2) {
particles.push({
x: goal.x + (Math.random()-0.5) * 40,
y: goal.y + (Math.random()-0.5) * 40,
vx: -1.0 - Math.random() * 1.5,
vy: (Math.random() - 0.5) * 0.8,
life: 400,
type: 'sweet',
size: Math.floor(Math.random() * 2) + 2
});
}
obstacles.forEach(obs => {
if (Math.random() < 0.08) {
particles.push({
x: obs.x + (Math.random()-0.5) * obs.radius,
y: obs.y + (Math.random()-0.5) * obs.radius,
vx: -0.8 - Math.random(),
vy: (Math.random() - 0.5) * 1.2,
life: 150,
type: 'repellent',
size: Math.floor(Math.random() * 2) + 2
});
}
});
}
function playSound(freq, type, duration = 0.15) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
gain.gain.exponentialRampToValueAtTime(0.00001, audioCtx.currentTime + duration);
osc.stop(audioCtx.currentTime + duration);
}
function drawDotButterfly(x, y, isSnagged) {
ctx.save();
ctx.translate(x, y);
const wingColor = isSnagged ? '#f44' : '#0ee';
const subColor = isSnagged ? '#a00' : '#088';
const bodyColor = '#fff';
const dot = 2;
const isFlapping = Math.floor(Date.now() / 100) % 2 === 0;
if (isFlapping) {
ctx.fillStyle = wingColor;
ctx.fillRect(-5*dot, -5*dot, 3*dot, 4*dot);
ctx.fillRect(2*dot, -5*dot, 3*dot, 4*dot);
ctx.fillStyle = subColor;
ctx.fillRect(-6*dot, -1*dot, 4*dot, 3*dot);
ctx.fillRect(2*dot, -1*dot, 4*dot, 3*dot);
} else {
ctx.fillStyle = wingColor;
ctx.fillRect(-6*dot, -2*dot, 4*dot, 3*dot);
ctx.fillRect(2*dot, -2*dot, 4*dot, 3*dot);
ctx.fillStyle = subColor;
ctx.fillRect(-5*dot, 1*dot, 3*dot, 4*dot);
ctx.fillRect(2*dot, 1*dot, 3*dot, 4*dot);
}
ctx.fillStyle = bodyColor;
ctx.fillRect(-1*dot, -3*dot, 2*dot, 6*dot);
ctx.fillStyle = subColor;
ctx.fillRect(-2*dot, -5*dot, 1*dot, 2*dot);
ctx.fillRect(1*dot, -5*dot, 1*dot, 2*dot);
ctx.restore();
}
function drawGlowingFlower(x, y, alpha, pulse) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.shadowBlur = 30 + Math.sin(pulse) * 10;
ctx.shadowColor = '#f0f';
ctx.fillStyle = '#f9a';
for(let i=0; i<8; i++) {
let angle = (i * Math.PI) / 4 + pulse * 0.3;
ctx.beginPath();
ctx.ellipse(x + Math.cos(angle)*12, y + Math.sin(angle)*12, 18, 5, angle, 0, Math.PI*2);
ctx.fill();
}
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
function drawGlowingObstacle(x, y, radius, alpha) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.shadowBlur = 20;
ctx.shadowColor = '#0f0';
ctx.strokeStyle = '#5f5';
ctx.lineWidth = 2;
ctx.beginPath();
for(let i=0; i<12; i++) {
let angle = (i * Math.PI) / 6;
let r = radius + (i % 2 === 0 ? 10 : -10);
let px = x + Math.cos(angle) * r;
let py = y + Math.sin(angle) * r;
if(i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = 'rgba(0, 50, 0, 0.5)';
ctx.fill();
ctx.restore();
}
function getRomanticPoemLines(timeInSeconds) {
let options = [];
if (timeInSeconds <= 18) {
options = [
"闇夜にも 迷はぬほどに 濃き匂ひ / 導く先は 君の腕の中",
"風に乗る 微かな香を 捕らへては / 焦がれる胸が 君へと急ぐ",
"幾千の 闇を越へても 違へじな / わが魂を 呼ぶその香りは",
"忌避の棘 恐るるに足らず 今宵こそ / 匂ひほころぶ 君に逢ふなり",
"月見えず 星も隠るる 宵なれど / 君の残り香 闇を切り裂く"
];
} else if (timeInSeconds <= 35) {
options = [
"探しても 見えぬ姿に 惑ふ夜 / されど確かな 君の残り香",
"見えねども 確かに其処に ある気配 / 香りをたより 君を尋ねむ",
"宵闇に 紛れて探す 恋の道 / 香りが繋ぐ 二人の絆",
"惑ひつつ 幾重の壁を 越えゆかむ / 甘き調べに 身を委ねつつ",
"ためらひの 羽根を休める 間もあらず / 君の匂ひに ただ惹かれゆく"
];
} else {
options = [
"待ちわびて 宵から夜更け 彷徨へど / 香り絶えねば いつか逢へるを",
"長き夜を 幾度迷ひて 過ぎぬれど / 逢瀬の香りは 色褪せざりけり",
"遠き君 隔てる闇の 深くとも / 想ひは香りの ごとく漂ふ",
"傷ついた 羽根を慰む 残り香よ / 長き迷ひも 報はれにけり",
"逢ひたくて 幾つの壁に 阻まれて / 辿り着きけり 君の咲く場所"
];
}
const selected = options[Math.floor(Math.random() * options.length)];
return selected.split(' / ');
}
window.addEventListener('keydown', e => keys[e.code] = true);
window.addEventListener('keyup', e => keys[e.code] = false);
canvas.addEventListener('mousedown', () => {
if (!isPlaying) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
isPlaying = true;
startTime = Date.now();
setInterval(sonarPulse, 400);
loop();
}
});
retryBtn.addEventListener('click', () => {
initGame();
if (!isPlaying) {
isPlaying = true;
loop();
}
});
function sonarPulse() {
if (!isPlaying || isGameOver) return;
const distGoal = Math.hypot(goal.x - player.x, goal.y - player.y);
let minDistObs = Infinity;
obstacles.forEach(obs => {
const dist = Math.hypot(obs.x - player.x, obs.y - player.y);
if (dist < minDistObs) minDistObs = dist;
});
if (distGoal < 450) {
playSound(900 - distGoal * 1.2, 'sine');
}
if (minDistObs < 150) {
setTimeout(() => playSound(150 + minDistObs, 'triangle'), 100);
}
}
let finalPoemLines = null;
function loop() {
if (!isPlaying) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
let currentTime = Date.now();
let displayTime = isGameOver ? (endTime - startTime) : (currentTime - startTime);
let secondsNum = displayTime / 1000;
let secondsStr = secondsNum.toFixed(2);
if (!isGameOver) {
goal.pulse += 0.05;
let currentSpeed = player.speed;
let isSnagged = false;
if (player.snagTimer > 0) {
player.snagTimer--;
currentSpeed = player.speed * 0.1;
isSnagged = true;
}
if (keys['ArrowUp']) player.y -= currentSpeed;
if (keys['ArrowDown']) player.y += currentSpeed;
if (keys['ArrowLeft']) player.x -= currentSpeed;
if (keys['ArrowRight']) player.x += currentSpeed;
player.x = Math.max(0, Math.min(canvas.width, player.x));
player.y = Math.max(0, Math.min(canvas.height, player.y));
if (!isSnagged && (keys['ArrowUp'] || keys['ArrowDown'] || keys['ArrowLeft'] || keys['ArrowRight'])) {
trails.push({ x: player.x, y: player.y, alpha: 1.0 });
}
obstacles.forEach(obs => {
const dx = player.x - obs.x;
const dy = player.y - obs.y;
const dist = Math.hypot(dx, dy);
if (dist < obs.radius) {
if (player.snagTimer === 0) {
player.snagTimer = 40;
playSound(100, 'sawtooth', 0.3);
}
player.x += (dx / dist) * currentSpeed * 2;
player.y += (dy / dist) * currentSpeed * 2;
obs.revealAlpha = 1.0;
}
});
const distToGoal = Math.hypot(player.x - goal.x, player.y - goal.y);
if (distToGoal < goal.radius) {
goal.revealAlpha = 1.0;
isGameOver = true;
endTime = currentTime;
finalPoemLines = getRomanticPoemLines(secondsNum);
playSound(800, 'sine', 0.5);
setTimeout(() => playSound(1200, 'sine', 1.0), 200);
setTimeout(() => playSound(1600, 'sine', 2.0), 400);
retryBtn.style.display = 'block';
}
spawnParticles();
}
for (let i = trails.length - 1; i >= 0; i--) {
let t = trails[i];
t.alpha -= 0.04;
ctx.fillStyle = `rgba(0, 255, 255, ${t.alpha * 0.5})`;
ctx.fillRect(t.x - 2, t.y - 2, 4, 4);
if (t.alpha <= 0) trails.splice(i, 1);
}
ctx.globalCompositeOperation = 'lighter';
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life--;
const alpha = Math.max(0, p.life / 400) * 0.35;
ctx.fillStyle = p.type === 'sweet'
? `rgba(255, 100, 200, ${alpha})`
: `rgba(100, 255, 100, ${alpha})`;
ctx.fillRect(p.x - p.size/2, p.y - p.size/2, p.size, p.size);
if (p.life <= 0) particles.splice(i, 1);
}
ctx.globalCompositeOperation = 'source-over';
obstacles.forEach(obs => {
if (obs.revealAlpha > 0) {
drawGlowingObstacle(obs.x, obs.y, obs.radius, obs.revealAlpha);
obs.revealAlpha -= 0.015;
}
});
if (goal.revealAlpha > 0) {
drawGlowingFlower(goal.x, goal.y, goal.revealAlpha, goal.pulse);
if (!isGameOver) goal.revealAlpha -= 0.015;
}
drawDotButterfly(player.x, player.y, player.snagTimer > 0);
if (!isGameOver) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '14px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${secondsStr}s`, canvas.width - 20, 30);
ctx.textAlign = 'left';
}
if (isGameOver && finalPoemLines) {
ctx.fillStyle = `rgba(0, 0, 0, ${Math.min(0.7, goal.revealAlpha)})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = `rgba(255, 200, 220, ${Math.min(1, goal.revealAlpha)})`;
ctx.textAlign = 'center';
ctx.font = '26px "Yu Mincho", "MS Mincho", serif';
ctx.shadowBlur = 15;
ctx.shadowColor = '#f9a';
ctx.fillText(finalPoemLines[0], canvas.width/2, canvas.height/2 - 20);
ctx.fillText(finalPoemLines[1], canvas.width/2, canvas.height/2 + 20);
ctx.font = '14px "Yu Mincho", "MS Mincho", serif';
ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(0.5, goal.revealAlpha)})`;
ctx.shadowBlur = 0;
ctx.fillText(`逢瀬までの刻:${secondsStr} 秒`, canvas.width/2, canvas.height/2 + 80);
ctx.textAlign = 'left';
}
requestAnimationFrame(loop);
}
ctx.fillStyle = '#fff';
ctx.font = '20px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('クリックして夜の世界へ', canvas.width/2, canvas.height/2);
ctx.textAlign = 'left';
</script>
</body>
</html>


■ 今日の学び

「雰囲気ゲーム」というジャンルがあるのかどうか、
以前はよく分からなかったのですが……

実は「雰囲気ゲー」という言葉は、
インディーゲーム界隈では
しっかりとしたジャンルとして認識されているそうです。( ˘꒳˘ )✨

今回は、そんな“見えないもの”をゲームで表現できないかなと、
AIへ相談したところから制作が始まりました。

注目したのは「香り」。

見えないけれど、風や重力、あるいは人の感情のように、
確かにそこにあって影響を与える存在です。

その香りを、匂いのない画面越しの世界でどう伝えるか──
AIと一緒に、ああでもないこうでもないと試行錯誤しました。

最終的には、微かな光の粒とピコピコという電子音を重ねて表現する、
静かでどこか古風なゲームに落ち着きました。
(見えないものを手探りで探すって、ちょっとロマンチックですよね)

いつものように、その日のうちに作り上げるHTMLゲームなので、
どこか不十分で荒削りな部分もあります。

でも今回も、その“未完成さ”を含めて、
心から楽しみながら仕上げることができました。(^^*)

【おまけ:これからの実験室について】

毎日コツコツ作っているうちに、
ブログに載せていないゲームがかなり溜まってきました。

実は「100のゲーム」という当初の目標をとうに超えて、
いまでは 160個近く の不思議なゲームたちが、
手元のメモ帳の中で出番を待っています。( ¯•ω•¯ )💭

せっかくなので、
ブログ内に「未記事ゲームの一覧ページ」となる
固定ページを作ってみようかなと考え中です。

そしてこれからは、皆さんから
「このゲームの記事が読みたい!」
「こんなゲームを見てみたい!」 といったリクエストをいただいて、
それをもとに記事化していくのも面白いかなと思っています。

160の実験を超えてさらに広がる、AIとのゲーム実験室を、
これからもよろしくお願いします! ヽ(>v<*)=3

■ 次回も、大切な人に会いに行くゲームです。

しかし、待ち合わせのたびに降り出すのは、決まって雨。
しかも土砂降りの大荒れ──。

それでもめげずに向かいます。
大切な人に会うために。

次回も、そんな“想いで進むゲーム”をお届けします。(*^v^)ノシ

▶ 次の実験はこちら  
(風雨の独り言実況シミュレーターゲーム)  
「土砂降り待ち合わせ」



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