実験#62:光と熱の収束点(ソーラー イグニッション)

■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つの夏の思い出
最初は「マッチ」からスタートしますが、

消火すると次は「手持ち花火」、

そして最後は「線香花火」へと進んでいきます。

線香花火の火の玉がぽとりと落ちて終わった後は、
「もう一度最初から」ボタンで何度でも遊べます。

ぜひ、あなただけの夏の海辺の実験を楽しんでみてくださいね。(´ω`*)

“コードは長いので折りたたんでいます”

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<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^*)


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

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

続きを読む