■62個目 ソーラー イグニッション
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今回は、ちょっぴり懐かしくて、
あたたかい夏の思い出をゲームにしてみました。
テーマは……ズバリ「虫眼鏡で火おこし」です!
舞台は、真夏のキラキラした砂浜。
波の音が「ちゃぷちゃぷ」と聞こえる中で、
太陽の光をじーっと集めたときの、あのジリジリとした感覚……
覚えていますか?(=v=*)
ゲームの中では、黒い紙を焦がして煙が上がったり、
マッチに火がついたりと、
子どもの頃のちょっとドキドキする実験を“安全に”楽しめます。
現実では危ないので、まねしないでくださいね。(*^_^*)
こだわったのは、火と煙のリアルな変化、そして音です。
マッチがついた瞬間の「ボッ!」という音や、
バケツのお水に入れたときの
「ジュッ!」という消火の感覚まで再現してみました。
さらに……マッチで遊んだあとは、「手持ち花火」と「線香花火」も登場します。
パチパチ……シュワワ……と移り変わる火花と音を眺めていると、
まるで夜の砂浜に座っているような、
のんびりした気分になれますよ。
■ AIとのやりとりで面白かった部分
■ゲーム仕様の相談からスタート
みー:(^v^)
今日もゲームを作ります。HTML形式で動かせるゲームです。
虫眼鏡発火ゲーム。
太陽光をレンズで収束させて、マッチの先端に火をつけるゲーム。
リアリティ重視です。
発火の瞬間の火の現れ方や煙の上り方まで、
HTMLでできる最大限の表現を目指したい。
黒い紙も置いて、焦点合わせの練習ができるようにしましょう。
操作はマウスで虫眼鏡を動かす感じで。
AI:
太陽光で火を起こすゲーム、いいテーマだね。
焦点が合ったときのジリジリした感覚や、
火がつく瞬間のドキッとする感じ……
大人になってもワクワクする実験だよね。
そういえば、太陽光で火をつける話でひとつ小話を。
オリンピックの聖火って、実はライターやマッチじゃなくて、
ギリシャのオリンピア遺跡で巫女役の女性が凹面鏡を使って
太陽光を一点に集めて点火しているんだよ。
まさに今回のゲームと同じ原理。
古代から続く“光の儀式”って、ちょっとロマンがあるよね。
みー:(=v=;)
板の上で実験してるのは危なそうなので、
真夏の砂浜で行う設定にしてほしいです。
安全のために水の入ったブリキのバケツも背景に置きたいです。
AI:
了解。舞台を砂浜に移して、バケツも描画したよ。
マウスで虫眼鏡を動かし、ホイールでピント調整ができます。
光の点が一番小さく明るくなったときが最高温度。
まずは左下の黒い紙で焦がす感覚を掴んでから、
右側のマッチに挑戦してみてね。
みー:(^_^)ノ
火のついたマッチを消火するボタンを作ろう。
押したら、マッチがバケツに入って
「ジュッ」と消えるようにできますか?
AI:
もちろん。
画面上部に「消火ボタン」を追加したよ。
マッチに火がついたときだけ押せるようになっていて、
クリックするとマッチがバケツへ移動し、
水に入った瞬間に白い蒸気のパーティクルが立ち上がるよ。
みー:(^o^)
手持ち花火も消火ボタンでバケツに入れて、
そのあと線香花火が出てくるようにしたいです。
線香花火は、
小さくパチパチ→大きくパチパチ→小さくパチパチ→極小→火の玉が落ちて終了、という流れでやってみましょう。
AI:
いいね。線香花火には実は名前がついていて、
最初の玉を「蕾」、
勢いよく散る火花を「牡丹」、
筋のように伸びるのを「松葉」、
最後の細かい火花を「散り菊」と呼ぶんだ。
わずか数十秒で花が咲いて散るまでを表現している、
日本らしい小さな芸術だよね。
今回のゲームでも、その時間変化をしっかり再現してみたよ。
■ 実際の動作
こちらが、AIが最初に作ってくれたゲームの画像です。

(画像:最初にAIが作ってくれたゲームの画像)
黒い紙で、太陽光の収束を練習できます。

(画像:スタート画面)
マウスのコロコロで光を絞ると、
黒い紙の上からふわっと煙が上がりはじめます。
そのままマッチの先に光を当てると、発火します。

(画像:火のついたマッチ)
マッチを消火すると、次は手持ち花火が現れます。
黒い紙が、ちょうど良い背景になってくれています。

(画像:手持ち花火)
手持ち花火を消火すると、線香花火がそっと登場します。 小さな火の玉が、時間とともに表情を変えていきます。

(画像:線香花火)
■ コードのポイント
今回の記事では、AI と相談しながら作った
「ソーラー イグニッション」 の HTML コードを公開します。
このコードをそのまま保存するだけで、
あなたのPCでもすぐに遊べます。
ブラウザだけで動く、とてもシンプルなゲームなので、
気軽に試してみてくださいね。
▼ 遊ぶまでの手順
①PC の「メモ帳」(※Windowsの場合)や
Mac の「テキストエディット」など、テキストエディタを開きます。
②下のコードをコピーして貼り付けます。
③「solar_ignition.html」など、好きな名前で保存します。
(※拡張子は .html にしてください)
④保存したファイルをブラウザで開けば、すぐに遊べます。
■ 遊び方・操作説明
それでは、この実験室の遊び方をご説明しますね!
【1】まずは画面を「ぽちっ」とクリック
今回のゲームは、リアルな音もこだわりの一つです。
ブラウザの仕組み上、
最初に画面のどこかをクリック(またはタップ)すると
音が鳴るようになります。ぜひ音を出して遊んでみてくださいね。
【2】マウスで虫眼鏡を動かそう
マウスを動かすと、虫眼鏡が一緒についてきます。
焦がしたい場所(黒い紙やマッチの先など)に
狙いを定めてみましょう。
【3】マウスホイールで「ピント」を合わせよう
ここが一番のポイントです!
マウスのホイール(真ん中のくるくる回すところ)を前後に動かすと、
光の大きさが変わります。
光の点が 一番小さく、明るくなった時 が最高温度。
じわじわと熱が溜まっていき、煙が上がり始めますよ。
【4】あぶなくなったら「消火ボタン」
火がついた後、画面の上に「マッチを消火する」「手持ち花火を消火する」というボタンが出ます。
ここを押すと、水の入ったバケツへ「ジュッ!」と
安全に火を消すことができます。
お片付けもバッチリですね。(^^*)
【5】3つの夏の思い出
最初は「マッチ」からスタートしますが、
消火すると次は「手持ち花火」、
そして最後は「線香花火」へと進んでいきます。
線香花火の火の玉がぽとりと落ちて終わった後は、
「もう一度最初から」ボタンで何度でも遊べます。
ぜひ、あなただけの夏の海辺の実験を楽しんでみてくださいね。(´ω`*)
“コードは長いので折りたたんでいます”
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <title>Solar Ignition Simulator - Sound & Sparkler Edition</title> <style> body { margin: 0; padding: 0; overflow: hidden; background-color: #1a1a1a; color: #333; font-family: 'Helvetica Neue', Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; } #gameContainer { position: relative; box-shadow: 0 0 20px rgba(0, 0, 0, 0.8); } #gameCanvas { display: block; cursor: none; } #ui { position: absolute; top: 20px; left: 20px; pointer-events: none; text-shadow: 1px 1px 2px rgba(255,255,255,0.8); } .instruction { font-size: 14px; font-weight: bold; margin-bottom: 5px; } .btn { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 25px; font-size: 16px; font-weight: bold; color: #fff; background-color: #d9534f; border: 2px solid #fff; border-radius: 8px; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.3); display: none; transition: background-color 0.2s; } .btn:hover { background-color: #c9302c; } #retryBtn { background-color: #5cb85c; top: 80px; } #retryBtn:hover { background-color: #4cae4c; } </style></head><body><div id="gameContainer"> <div id="ui"> <div class="instruction">【操作方法】</div> <div class="instruction">マウス移動:虫眼鏡の移動(※最初に画面をクリックすると音源が有効になります)</div> <div class="instruction">マウスホイール:ピント調整(光を最小にすると温度上昇)</div> </div> <button id="extinguishBtn" class="btn">マッチを消火する</button> <button id="retryBtn" class="btn">もう一度最初から</button> <canvas id="gameCanvas" width="800" height="600"></canvas></div><script> const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const extinguishBtn = document.getElementById('extinguishBtn'); const retryBtn = document.getElementById('retryBtn'); // --- 効果音エンジン (Web Audio API) --- const SoundEngine = { ctx: null, init() { if (!this.ctx) { const AudioContext = window.AudioContext || window.webkitAudioContext; if(AudioContext) this.ctx = new AudioContext(); } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } }, createNoise(duration) { if(!this.ctx) return null; const bufferSize = this.ctx.sampleRate * duration; const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); const output = buffer.getChannelData(0); for (let i = 0; i < bufferSize; i++) { output[i] = Math.random() * 2 - 1; } return buffer; }, playIgnite() { // ボッ(着火) if(!this.ctx) return; const noise = this.ctx.createBufferSource(); noise.buffer = this.createNoise(0.3); const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 500; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0.8, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.3); noise.connect(filter); filter.connect(gain); gain.connect(this.ctx.destination); noise.start(); }, playExtinguish() { // ジュッ(水に入る) if(!this.ctx) return; const noise = this.ctx.createBufferSource(); noise.buffer = this.createNoise(0.5); const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 1500; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(1, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.5); noise.connect(filter); filter.connect(gain); gain.connect(this.ctx.destination); noise.start(); }, playPop(vol = 0.5) { // パチッ(火花) if(!this.ctx) return; const noise = this.ctx.createBufferSource(); noise.buffer = this.createNoise(0.05); const filter = this.ctx.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.value = 3000; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(vol, this.ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.05); noise.connect(filter); filter.connect(gain); gain.connect(this.ctx.destination); noise.start(); }, playSizzle(duration, vol = 0.2) { // シュワワ・シュシュシュ if(!this.ctx) return; const noise = this.ctx.createBufferSource(); noise.buffer = this.createNoise(duration); const filter = this.ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 4000; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(0, this.ctx.currentTime); gain.gain.linearRampToValueAtTime(vol, this.ctx.currentTime + 0.1); gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration); noise.connect(filter); filter.connect(gain); gain.connect(this.ctx.destination); noise.start(); } }; // システム状態 let mouse = { x: 400, y: 300 }; let focusLevel = 50; let particles = []; let burnMarks = []; let flashAlpha = 0; let activeScene = 'match'; let extinguishPhase = 0; // オブジェクト設定 const paper = { x: 135, y: 250, width: 350, height: 250, heat: 0 }; const bucket = { x: 650, y: 200, radius: 45 }; let match = { x: 500, y: 400, headRadius: 15, heat: 0, isIgnited: false, isExtinguished: false, z: 0, alpha: 1.0 }; let firework = { x: 500, y: 380, heat: 0, state: 0, timer: 0, alpha: 0, z: 0, isIgnited: false, isExtinguished: false }; // 線香花火を前回の230からマッチの頭1個分(15px)下げて245に設定 let sparkler = { x: 310, y: 245, heat: 0, state: 0, timer: 0, alpha: 0, ballY: 0, ballRadius: 0 }; // 初期化関数 function resetGame() { activeScene = 'match'; extinguishPhase = 0; particles = []; burnMarks = []; flashAlpha = 0; paper.heat = 0; match = { x: 500, y: 400, headRadius: 15, heat: 0, isIgnited: false, isExtinguished: false, z: 0, alpha: 1.0 }; firework = { x: 500, y: 380, heat: 0, state: 0, timer: 0, alpha: 0, z: 0, isIgnited: false, isExtinguished: false }; sparkler = { x: 310, y: 245, heat: 0, state: 0, timer: 0, alpha: 0, ballY: 0, ballRadius: 0 }; extinguishBtn.innerText = 'マッチを消火する'; extinguishBtn.style.display = 'none'; retryBtn.style.display = 'none'; } // イベントリスナー window.addEventListener('mousedown', () => { SoundEngine.init(); // クリックでオーディオ有効化 }); window.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; }); window.addEventListener('wheel', (e) => { focusLevel += e.deltaY * 0.05; if (focusLevel < 0) focusLevel = 0; if (focusLevel > 100) focusLevel = 100; e.preventDefault(); }, { passive: false }); extinguishBtn.addEventListener('click', () => { SoundEngine.init(); if (activeScene === 'match' && match.isIgnited && extinguishPhase === 0) { extinguishPhase = 1; extinguishBtn.style.display = 'none'; } else if (activeScene === 'firework' && firework.isIgnited && extinguishPhase === 0) { extinguishPhase = 1; extinguishBtn.style.display = 'none'; } }); retryBtn.addEventListener('click', resetGame); // パーティクルクラス class Particle { constructor(x, y, type, customSize = null) { this.x = x; this.y = y; this.type = type; this.life = 1.0; if (this.type === 'smoke') { this.vx = (Math.random() - 0.5) * 1.5; this.vy = -Math.random() * 2 - 1; this.size = Math.random() * 5 + 5; this.color = `rgba(50, 50, 50, `; this.decay = Math.random() * 0.01 + 0.005; } else if (this.type === 'firework_smoke') { this.vx = (Math.random() - 0.5) * 2; this.vy = -Math.random() * 3 - 1; this.size = Math.random() * 6 + 6; this.color = `rgba(220, 220, 220, `; this.decay = Math.random() * 0.015 + 0.01; } else if (this.type === 'steam') { this.vx = (Math.random() - 0.5) * 4; this.vy = -Math.random() * 5 - 2; this.size = Math.random() * 10 + 10; this.color = `rgba(220, 220, 230, `; this.decay = Math.random() * 0.015 + 0.01; } else if (this.type === 'spark') { this.vx = -Math.random() * 8 - 2; this.vy = (Math.random() - 0.5) * 6; this.size = Math.random() * 4 + 2; const colors = ['#FF3366', '#33FF66', '#33CCFF', '#FFFF33', '#FF9933', '#FFFFFF']; this.baseColorHex = colors[Math.floor(Math.random() * colors.length)]; this.decay = Math.random() * 0.02 + 0.01; } else if (this.type === 'senko_spark') { const angle = Math.random() * Math.PI * 2; const speed = customSize || (Math.random() * 3 + 1); this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed; this.size = Math.random() * 2 + 1; this.baseColorHex = Math.random() > 0.5 ? '#ffaa00' : '#ff4400'; this.decay = Math.random() * 0.05 + 0.02; } else { // fire this.vx = (Math.random() - 0.5) * 3; this.vy = -Math.random() * 4 - 2; this.size = Math.random() * 8 + 4; const colors = ['255, 100, 0', '255, 200, 0', '255, 50, 0']; this.baseColor = colors[Math.floor(Math.random() * colors.length)]; this.decay = Math.random() * 0.02 + 0.02; } } update() { this.x += this.vx; this.y += this.vy; this.life -= this.decay; if (this.type === 'smoke' || this.type === 'steam' || this.type === 'firework_smoke') { this.size += 0.3; } else if (this.type === 'spark' || this.type === 'senko_spark') { this.vy += 0.1; // 重力 this.size *= 0.96; } else { this.size *= 0.95; } } draw(ctx) { ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); if (this.type === 'smoke' || this.type === 'steam' || this.type === 'firework_smoke') { ctx.fillStyle = this.color + this.life * 0.4 + ')'; ctx.fill(); } else if (this.type === 'spark' || this.type === 'senko_spark') { ctx.fillStyle = this.baseColorHex; ctx.globalAlpha = this.life; ctx.fill(); ctx.globalAlpha = 1.0; } else { ctx.fillStyle = `rgba(${this.baseColor}, ${this.life})`; ctx.shadowBlur = 10; ctx.shadowColor = `rgba(${this.baseColor}, 1)`; ctx.fill(); ctx.shadowBlur = 0; } } } function createParticles(x, y, type, count, customSize = null) { for (let i = 0; i < count; i++) { particles.push(new Particle(x, y, type, customSize)); } } // --- 描画系 --- function drawEnvironment() { ctx.fillStyle = '#87CEEB'; ctx.fillRect(0, 0, canvas.width, 150); ctx.fillStyle = '#1E90FF'; ctx.fillRect(0, 150, canvas.width, 40); ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 190); for (let i = 0; i < canvas.width; i += 20) { ctx.lineTo(i, 190 + Math.sin(Date.now() / 300 + i) * 2); } ctx.stroke(); ctx.fillStyle = '#eaddaa'; ctx.fillRect(0, 190, canvas.width, canvas.height - 190); ctx.fillStyle = 'rgba(200, 180, 130, 0.5)'; for (let i = 0; i < 100; i++) { let sx = Math.random() * canvas.width; let sy = 190 + Math.random() * (canvas.height - 190); ctx.fillRect(sx, sy, 2, 2); } } function drawBucket() { const time = Date.now() / 200; ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; ctx.beginPath(); ctx.ellipse(bucket.x + 10, bucket.y + 15, bucket.radius, 15, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#999'; ctx.beginPath(); ctx.ellipse(bucket.x, bucket.y, bucket.radius, 15, 0, 0, Math.PI * 2); ctx.fill(); const gradient = ctx.createLinearGradient(bucket.x - bucket.radius, 0, bucket.x + bucket.radius, 0); gradient.addColorStop(0, '#777'); gradient.addColorStop(0.5, '#bbb'); gradient.addColorStop(1, '#666'); ctx.fillStyle = gradient; ctx.fillRect(bucket.x - bucket.radius, bucket.y - 60, bucket.radius * 2, 60); ctx.fillStyle = '#888'; ctx.beginPath(); ctx.ellipse(bucket.x, bucket.y - 60, bucket.radius, 15, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(60, 150, 200, 0.9)'; ctx.beginPath(); const wave = Math.sin(time) * 2; ctx.ellipse(bucket.x, bucket.y - 50 + wave, bucket.radius - 4, 12, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; ctx.beginPath(); ctx.ellipse(bucket.x - 10, bucket.y - 52 + wave, 10, 4, Math.PI/8, 0, Math.PI * 2); ctx.fill(); } function drawPaper() { ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.fillRect(paper.x + 8, paper.y + 8, paper.width, paper.height); ctx.fillStyle = '#111'; ctx.fillRect(paper.x, paper.y, paper.width, paper.height); burnMarks.forEach(mark => { ctx.beginPath(); ctx.arc(mark.x, mark.y, mark.size, 0, Math.PI * 2); ctx.fillStyle = `rgba(0, 0, 0, ${mark.alpha})`; ctx.fill(); }); ctx.restore(); } function drawMatch() { if (match.alpha <= 0) return; ctx.save(); ctx.globalAlpha = match.alpha; ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.beginPath(); ctx.ellipse(match.x + 75 - match.z * 0.5, match.y + 4 + match.z * 0.5, 75, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.translate(0, -match.z); ctx.fillStyle = match.isExtinguished ? '#443' : '#8B4513'; ctx.fillRect(match.x, match.y, 150, 8); ctx.strokeStyle = '#3e1d04'; ctx.lineWidth = 1; ctx.strokeRect(match.x, match.y, 150, 8); ctx.beginPath(); ctx.arc(match.x, match.y + 4, match.headRadius, 0, Math.PI * 2); if (match.isExtinguished || match.isIgnited) { ctx.fillStyle = '#111'; } else { const darken = Math.min(255, match.heat * 2.5); ctx.fillStyle = `rgb(${200 - darken}, ${50 - darken/2}, ${50 - darken/2})`; } ctx.fill(); ctx.restore(); } function drawFirework() { if (firework.alpha <= 0) return; ctx.save(); ctx.globalAlpha = firework.alpha; ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.beginPath(); ctx.ellipse(firework.x + 60 - firework.z * 0.5, firework.y + 4 + firework.z * 0.5, 70, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.translate(0, -firework.z); for (let i = 0; i < 10; i++) { ctx.fillStyle = firework.isExtinguished ? '#444' : (i % 2 === 0 ? '#ff6b6b' : '#feca57'); ctx.fillRect(firework.x + 15 + i * 13, firework.y, 14, 8); } ctx.strokeStyle = '#d35400'; ctx.strokeRect(firework.x + 15, firework.y, 130, 8); ctx.fillStyle = '#222'; ctx.beginPath(); ctx.moveTo(firework.x + 15, firework.y - 2); ctx.lineTo(firework.x - 15, firework.y + 4); ctx.lineTo(firework.x + 15, firework.y + 10); ctx.fill(); ctx.restore(); } function drawSparkler() { if (sparkler.alpha <= 0) return; ctx.save(); ctx.globalAlpha = sparkler.alpha; ctx.strokeStyle = '#cda57d'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(sparkler.x, 0); for(let i=0; i<sparkler.y; i+=10) { ctx.lineTo(sparkler.x + Math.sin(i*0.5)*2, i); } ctx.stroke(); ctx.fillStyle = '#222'; ctx.beginPath(); ctx.moveTo(sparkler.x - 3, sparkler.y); ctx.lineTo(sparkler.x + 3, sparkler.y); ctx.lineTo(sparkler.x, sparkler.y + 15); ctx.fill(); if (sparkler.ballRadius > 0) { ctx.beginPath(); ctx.arc(sparkler.x, sparkler.y + 15 + sparkler.ballY, sparkler.ballRadius, 0, Math.PI * 2); ctx.fillStyle = '#ff4400'; ctx.shadowBlur = 10; ctx.shadowColor = '#ff4400'; ctx.fill(); ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(sparkler.x, sparkler.y + 15 + sparkler.ballY, sparkler.ballRadius * 0.5, 0, Math.PI * 2); ctx.fillStyle = '#ffcc00'; ctx.fill(); } ctx.restore(); } function drawLightAndLens() { const spotRadius = 5 + focusLevel; const heatPower = Math.max(0, 10 - focusLevel) / 10; ctx.beginPath(); ctx.moveTo(mouse.x - 60, mouse.y - 100); ctx.lineTo(mouse.x + 60, mouse.y - 100); ctx.lineTo(mouse.x + spotRadius, mouse.y); ctx.lineTo(mouse.x - spotRadius, mouse.y); ctx.closePath(); const gradient = ctx.createLinearGradient(0, mouse.y - 100, 0, mouse.y); gradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)'); gradient.addColorStop(1, `rgba(255, 255, 200, ${0.1 + heatPower * 0.4})`); ctx.fillStyle = gradient; ctx.fill(); ctx.beginPath(); ctx.arc(mouse.x, mouse.y, spotRadius, 0, Math.PI * 2); ctx.fillStyle = `rgba(255, 255, 255, ${0.3 + heatPower * 0.7})`; ctx.shadowBlur = heatPower * 20; ctx.shadowColor = '#fff'; ctx.fill(); ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(mouse.x, mouse.y - 100, 60, 0, Math.PI * 2); ctx.fillStyle = 'rgba(200, 255, 255, 0.1)'; ctx.fill(); ctx.lineWidth = 5; ctx.strokeStyle = '#333'; ctx.stroke(); ctx.beginPath(); ctx.moveTo(mouse.x + 42, mouse.y - 58); ctx.lineTo(mouse.x + 120, mouse.y + 20); ctx.lineWidth = 15; ctx.strokeStyle = '#8b4513'; ctx.stroke(); } // --- ロジック更新 --- function updateLogic() { const spotRadius = 5 + focusLevel; const heatPower = (focusLevel < 10) ? (10 - focusLevel) / 10 : 0; // 黒い紙の判定 if (heatPower > 0 && mouse.x >= paper.x && mouse.x <= paper.x + paper.width && mouse.y >= paper.y && mouse.y <= paper.y + paper.height) { paper.heat += heatPower; if (Math.random() < heatPower * 0.5) createParticles(mouse.x, mouse.y, 'smoke', 1); if (paper.heat > 20 && Math.random() < 0.1) { burnMarks.push({ x: mouse.x + (Math.random()-0.5)*10, y: mouse.y + (Math.random()-0.5)*10, size: Math.random() * 3 + 2, alpha: Math.random() * 0.5 + 0.1 }); } } else { paper.heat = Math.max(0, paper.heat - 0.5); } // --- 消火アニメーション処理 --- if (extinguishPhase > 0) { let currentObj = (activeScene === 'match') ? match : firework; const targetX = bucket.x - 20; const targetY = bucket.y - 45; if (extinguishPhase === 1) { currentObj.z += 3; if (currentObj.z > 60) extinguishPhase = 2; } else if (extinguishPhase === 2) { const dx = targetX - currentObj.x; const dy = targetY - currentObj.y; currentObj.x += dx * 0.08; currentObj.y += dy * 0.08; if (Math.abs(dx) < 5 && Math.abs(dy) < 5) extinguishPhase = 3; } else if (extinguishPhase === 3) { currentObj.z -= 4; if (currentObj.z <= 0) { currentObj.z = 0; currentObj.isIgnited = false; currentObj.isExtinguished = true; extinguishPhase = 4; SoundEngine.playExtinguish(); // ジュッ! createParticles(currentObj.x, currentObj.y, 'steam', 40); } } else if (extinguishPhase === 4) { currentObj.alpha -= 0.02; if (currentObj.alpha <= 0) { extinguishPhase = 0; if (activeScene === 'match') { activeScene = 'firework'; firework.state = 1; extinguishBtn.innerText = '手持ち花火を消火する'; } else if (activeScene === 'firework') { activeScene = 'sparkler'; sparkler.state = 1; } } } } // --- シーン別判定 --- if (activeScene === 'match' && extinguishPhase === 0) { const dx = mouse.x - match.x; const dy = mouse.y - (match.y + 4); const dist = Math.sqrt(dx * dx + dy * dy); if (!match.isIgnited && heatPower > 0 && dist < match.headRadius + spotRadius) { match.heat += heatPower * 1.5; if (match.heat > 30 && Math.random() < heatPower) { createParticles(match.x, match.y, 'smoke', 2); } if (match.heat > 100) { match.isIgnited = true; flashAlpha = 1.0; SoundEngine.playIgnite(); // ボッ createParticles(match.x, match.y, 'fire', 50); extinguishBtn.style.display = 'block'; } } else if (!match.isIgnited) { match.heat = Math.max(0, match.heat - 1); } if (match.isIgnited) { if (Math.random() < 0.8) createParticles(match.x, match.y - match.z, 'fire', 3); if (Math.random() < 0.5) createParticles(match.x, match.y - 10 - match.z, 'smoke', 1); } } else if (activeScene === 'firework') { if (firework.state === 1 && firework.alpha < 1.0) firework.alpha += 0.02; const tipX = firework.x - 10; const tipY = firework.y + 4; if (firework.state === 1 && extinguishPhase === 0) { const dx = mouse.x - tipX; const dy = mouse.y - tipY; const dist = Math.sqrt(dx * dx + dy * dy); if (heatPower > 0 && dist < spotRadius + 15) { firework.heat += heatPower * 1.5; if (firework.heat > 30 && Math.random() < heatPower) createParticles(tipX, tipY, 'firework_smoke', 1); if (firework.heat > 100) { firework.state = 2; firework.timer = 180; SoundEngine.playIgnite(); // ボッ createParticles(tipX, tipY, 'fire', 15); flashAlpha = 0.5; firework.isIgnited = true; extinguishBtn.style.display = 'block'; } } else { firework.heat = Math.max(0, firework.heat - 1); } } else if (firework.state === 2 && extinguishPhase === 0) { firework.timer--; if (Math.random() < 0.4) createParticles(tipX, tipY, 'fire', 1); if (Math.random() < 0.6) createParticles(tipX, tipY, 'firework_smoke', 1); if (firework.timer <= 0) { firework.state = 3; flashAlpha = 0.8; } } else if (firework.state === 3 && extinguishPhase === 0) { createParticles(tipX - 5, tipY - firework.z, 'spark', 5); if (Math.random() < 0.5) createParticles(tipX - 5, tipY - firework.z, 'firework_smoke', 1); if (Math.random() < 0.1) SoundEngine.playSizzle(0.1, 0.1); // 手持ち花火の噴出音 } } else if (activeScene === 'sparkler') { if (sparkler.state === 1) { sparkler.alpha += 0.02; if (sparkler.alpha >= 1) sparkler.state = 2; } const tipX = sparkler.x; const tipY = sparkler.y + 15; if (sparkler.state === 2) { const dx = mouse.x - tipX; const dy = mouse.y - tipY; const dist = Math.sqrt(dx * dx + dy * dy); if (heatPower > 0 && dist < spotRadius + 10) { sparkler.heat += heatPower * 2.0; if (sparkler.heat > 30 && Math.random() < heatPower) createParticles(tipX, tipY, 'smoke', 1); if (sparkler.heat > 100) { sparkler.state = 3; sparkler.timer = 180; SoundEngine.playSizzle(3, 0.4); // シュワワ音 } } else { sparkler.heat = Math.max(0, sparkler.heat - 1); } } else if (sparkler.state === 3) { // シュワワ sparkler.timer--; if(sparkler.ballRadius < 4) sparkler.ballRadius += 0.05; if (Math.random() < 0.5) createParticles(tipX, tipY, 'smoke', 1); if (sparkler.timer <= 0) { sparkler.state = 4; sparkler.timer = 120; } } else if (sparkler.state === 4) { // 小さくパチパチ sparkler.timer--; if (Math.random() < 0.3) createParticles(tipX, tipY, 'senko_spark', 2, 2); if (Math.random() < 0.15) SoundEngine.playPop(0.3); // パチッ音 if (sparkler.timer <= 0) { sparkler.state = 5; sparkler.timer = 300; } } else if (sparkler.state === 5) { // 大きくパチパチ sparkler.timer--; if (Math.random() < 0.8) createParticles(tipX, tipY, 'senko_spark', 4, 5); if (Math.random() < 0.4) SoundEngine.playPop(0.7); // 強いパチッ音 if (sparkler.timer <= 0) { sparkler.state = 6; sparkler.timer = 180; } } else if (sparkler.state === 6) { // 小さくパチパチ sparkler.timer--; if (Math.random() < 0.4) createParticles(tipX, tipY, 'senko_spark', 2, 2); if (Math.random() < 0.2) SoundEngine.playPop(0.3); // パチッ音 if (sparkler.timer <= 0) { sparkler.state = 7; sparkler.timer = 180; } } else if (sparkler.state === 7) { // 極小シュシュシュ sparkler.timer--; if (Math.random() < 0.2) createParticles(tipX, tipY, 'senko_spark', 1, 0.5); if (sparkler.timer % 30 === 0) SoundEngine.playSizzle(0.5, 0.1); // シュシュシュ音 if (sparkler.timer <= 0) { sparkler.state = 8; } } else if (sparkler.state === 8) { // 落下 sparkler.ballY += 2; sparkler.alpha -= 0.02; if (sparkler.alpha <= 0) { sparkler.state = 9; retryBtn.style.display = 'block'; } } } for (let i = particles.length - 1; i >= 0; i--) { particles[i].update(); if (particles[i].life <= 0) particles.splice(i, 1); } if(flashAlpha > 0) flashAlpha -= 0.05; } // --- メインループ --- function gameLoop() { updateLogic(); ctx.clearRect(0, 0, canvas.width, canvas.height); drawEnvironment(); drawBucket(); drawPaper(); drawMatch(); drawFirework(); drawSparkler(); particles.forEach(p => { if (p.type === 'fire' || p.type === 'spark' || p.type === 'senko_spark') { ctx.globalCompositeOperation = 'lighter'; } else { ctx.globalCompositeOperation = 'source-over'; } p.draw(ctx); }); ctx.globalCompositeOperation = 'source-over'; drawLightAndLens(); if (flashAlpha > 0) { ctx.fillStyle = `rgba(255, 255, 255, ${flashAlpha})`; ctx.fillRect(0, 0, canvas.width, canvas.height); } requestAnimationFrame(gameLoop); } // 開始 gameLoop();</script></body></html>
■ 今日の学び
今回の小さな実験、いかがでしたか?
このゲームを作りながら、
思いがけない「学び」や「気づき」に出会いました。
【1】「におい」まで蘇る、記憶のシミュレーション
テストプレイ中、黒い紙からモクモクと煙が上がった瞬間……
小学校のころにやった
「虫眼鏡実験」の記憶がふっと蘇りました。(´▽`*)
あの時の太陽のまぶしさ、紙が焦げる独特のにおい。
画面から本物のにおいは出ないのに、
煙の揺れ方や、じわじわ焦げていく様子、
そして着火の「ボッ」という音が、記憶の引き出しをそっと開けてくれるんです。
遊んでくれる方の心にも、
「あの頃のにおい」や「夏の日の記憶」がふわっと戻ってきたらいいな……。
そんな想いを込めて、五感に寄り添う表現を大切にしました。(=v=*)
【2】「黒い紙」が教えてくれた、偶然のコントラスト
もう一つ、作っていて驚いたことがあります。
最初はただの“焦点合わせの練習用”として置いただけの黒い紙。
ところが、マッチから手持ち花火、線香花火へと進むにつれて、
この黒い紙が、色鮮やかな火花を美しく引き立てる
“最高のステージ”に変わっていったんです。
綺麗な光には、深い影が必要なんですね。
ただの背景が、偶然にも作品を支える大切な存在になる。
こうした思いがけない「光と影の発見」があるから、
ゲーム作りってやっぱり楽しくてやめられません。(*^v^*)
皆さんも、この小さな黒い紙の上で、
昔の懐かしいにおいを思い出しながら、
あなただけの綺麗な光を見つけてみてくださいね。
■ 次回は、イシガメのタンタン VS ピラニアのゲームです。
次の実験では、あのイシガメのタンタンが主役に戻ってきます。(^v^)
迫りくるピラニアの大群。
タンタンの鉄壁の守りとスキルを駆使して、
水中から押し寄せる猛攻を防ぎ切りましょう!
次回もぜひ遊びに来てくださいね。(*^v^)ノシ
▶ 次の実験はこちら(防ぎきれない攻撃と鉄壁の防御)
「カメバリア」
✨今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)

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