■25個目「ホットスターラーシミュレーター」
こんにちは、AIと作る100のゲーム実験室の みー です。(*^_^*)/
今日は「ホットスターラーシミュレーター」という、
実験要素の強いゲームその3…というより、
実験器具の再現に特化したゲームになっています。
ホットスターラーとは、磁力でビーカー内の磁石を回転させて 液体を撹拌する、あの素敵な実験機器です。
滴定しながら液の変化を見たり、温度を保ちながら混ぜたりと、
実験ではとても重宝するアイテムです。(*^^*)
好きすぎて、家で使い道がないのに家にあります。
温めながら撹拌できるタイプは高額なので、
うちにあるのは撹拌だけのマグネティックスターラーですが、
渦巻きを見ているだけで癒されます。ヽ(@_@*)ノ
今回はそんな素敵な機械を再現したくて、
ゲームっぽくないですが、できるだけ忠実に作ってみました。
制作の裏側は、この次の「AIとのやりとりで面白かった部分」で
少し長めに紹介します。
■ AIとのやりとりで面白かった部分
(^^*)今日もゲームを作ります。
実験で使うホットスターラー、マグネティックスターラーが好きで
家にも機械を持っています。
今回やりたいのは、ビーカーの中で攪拌子(マグネット)が回転して、
回転数に合わせて渦ができていく様子を再現すること。
回転速度の調整もマウスでできるといいね。
(AI)いらっしゃいませ!お、今日はマグネティックスターラーですか。
あの無機質な攪拌子がカタカタと回り始めて、
美しい渦が吸い込まれるようにできていく様子……ずっと見ていられますよね。
自前の実機をお持ちとは、なかなかのこだわりです!
(^_^)/ 今の液体量だと、渦ができてマグネット近くまでじょうご状になるよ。
それ以上に回転数を上げると暴れだします。
この“暴れ方”も再現できるかな。
(AI)さすが実機ユーザー!
渦が深くなりすぎて空気が中心まで到達し、そこから一気に暴れ出す
**脱調(Spin-out)**の恐怖……これを再現するとリアリティが一気に増しますね。
(^^*)ビーカーのメモリも入れよう。300mLサイズの想定で。
攪拌子はテフロンコーティングだから、暴走時の音は金属じゃなくて
「カツカツッ」「カラカラッ」って感じだよね。
音も付けたいな。
(AI)確かに!あの鈍い音、焦りますよね。
ちなみに海外では攪拌子のことを「Flea(ノミ)」と呼ぶこともありますよ。
(つ_<*)蒸気の表現は今の感じとてもいいです。
渦が深くなると、外側の水面は上昇するんじゃないかな。
(AI)おっしゃる通り!
遠心力で中央が下がる分、外側は壁を這い上がるように上昇します。
これは物理の基本でしたね。
(^o^;)脱調したら渦が静まっていくようにしましょう。
(AI)なるほど!脱調中は液体を押し流す力が失われるから、
渦がスッと消えていくのが正しい動きですね。
(AI)今日のマスターの小話:
攪拌子にはいろんな形があって、丸底フラスコ用には
**オーバル型(フットボール型)**が使われるんですよ。
(*^^)家で使ってるのはオーバル型だよ。
外側の水面の高さに合わせて半透明の図形を重ねると、
渦の奥行きが表現できるかな。
(AI)おう、いらっしゃい!
そのアイデアはまさに「アルファブレンド」を使った奥行き表現の基本だよ。
〈何やらAIの言葉遣いが馴れ馴れしくなってます。(^^;)〉
■ 実際の動作
一番最初に、AIとの相談で作ってもらったゲームの初期状態です。
実際のホットスターラーの動きを思い出しながら、
少しずつ提案して改良していきました。

(画像:AIが最初に作った状態)
ビーカーが中央にあり、マウスで回転数を調整できます。

(画像:スタート画面)
回転を上げすぎると、脱調して攪拌子(マグネット)が暴れます。

(画像:脱調して渦がなくなる様子)
液の色は自由に変えられます。

(画像:液の色を変えた様子)
■ コードのポイント
今回の記事では、AI と相談しながら作った
「ホットスターラーシミュレーター」の HTML コードを公開します。
ブラウザだけで動く、とてもシンプルなゲームです。
PC の「メモ帳」(※Windowsの場合)を開き、
下のコードをコピーして貼り付け、「hotsutara.html」など
好きな名前で保存してください。
保存したファイルをブラウザで開くだけで遊べます。
【操作方法】
・ホットスターラーの電源を ON にします。(クリックで切り替え)
・スピードと温度をマウスで調整してください。
※脱調した際は回転を下げると安定します。
・お好みで液体の色を変えることもできます。
コードといっても難しい部分はありません。
貼り付けて保存するだけで動きます。
▼ここからコード▼(クリックで開く)
<html lang="ja"><head> <meta charset="UTF-8"> <title>Realistic Hot Stirrer Sim v7</title> <style> body { background: #111; color: #eee; display: flex; flex-direction: column; align-items: center; font-family: 'Courier New', Courier, monospace; } canvas { background: radial-gradient(circle at center bottom, #3a3a3a 0%, #1a1a1a 70%); border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); margin-bottom: 20px;} .panel { background: #222; padding: 20px; border-radius: 10px; border: 2px solid #555; width: 450px; box-shadow: inset 0 0 10px #000; } .row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } input[type="range"] { flex-grow: 1; margin: 0 15px; cursor: pointer; accent-color: #00d4ff; } input[type="color"] { cursor: pointer; border: none; background: none; width: 40px; height: 30px; } button { background: #ff4444; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; font-weight: bold; width: 100%; transition: 0.3s;} button.on { background: #00cc66; } .label { width: 120px; font-weight: bold; color: #aaa; } .value-disp { width: 60px; text-align: right; color: #00d4ff; font-weight: bold; } .warning { color: #ff4444; font-size: 0.9rem; text-align: center; height: 1.2em; margin-bottom: 10px; } </style></head><body> <h2>Laboratory Hot Stirrer v7.0</h2> <canvas id="stirCanvas" width="500" height="550"></canvas> <div class="panel"> <button id="powerBtn">電源 OFF (クリックでON)</button> <div id="warningText" class="warning"></div> <div class="row"> <span class="label">回転 (RPM)</span> <input type="range" id="rpmSlider" min="0" max="2000" value="0" disabled> <span class="value-disp" id="rpmDisplay">0</span> </div> <div class="row"> <span class="label">温度 (℃)</span> <input type="range" id="tempSlider" min="20" max="150" value="20" disabled> <span class="value-disp" id="tempDisplay">20</span> </div> <div class="row"> <span class="label">溶媒カラー</span> <input type="color" id="colorPicker" value="#00d4ff"> </div> </div><script>const canvas = document.getElementById('stirCanvas');const ctx = canvas.getContext('2d');const powerBtn = document.getElementById('powerBtn');const rpmSlider = document.getElementById('rpmSlider');const tempSlider = document.getElementById('tempSlider');const colorPicker = document.getElementById('colorPicker');const rpmDisplay = document.getElementById('rpmDisplay');const tempDisplay = document.getElementById('tempDisplay');const warningText = document.getElementById('warningText');let isPowerOn = false;let angle = 0, shakeX = 0, shakeY = 0, isOutOfSync = false;let effectiveRpm = 0; let steamParticles = [];let audioCtx, motorOsc, motorGain, lastClackTime = 0;powerBtn.addEventListener('click', () => { isPowerOn = !isPowerOn; if (isPowerOn) { powerBtn.innerText = "電源 ON"; powerBtn.classList.add("on"); rpmSlider.disabled = false; tempSlider.disabled = false; if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); motorOsc = audioCtx.createOscillator(); motorGain = audioCtx.createGain(); motorOsc.type = 'sine'; motorOsc.connect(motorGain); motorGain.connect(audioCtx.destination); motorOsc.start(); motorGain.gain.value = 0; } } else { powerBtn.innerText = "電源 OFF (クリックでON)"; powerBtn.classList.remove("on"); rpmSlider.disabled = true; tempSlider.disabled = true; rpmSlider.value = 0; if(motorGain) motorGain.gain.value = 0; }});function playClackSound() { if (!audioCtx || audioCtx.currentTime - lastClackTime < 0.08) return; lastClackTime = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(700 + Math.random() * 300, audioCtx.currentTime); gain.gain.setValueAtTime(0.2, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.04); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.04);}function hexToRgba(hex, alpha) { let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`;}function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); const rpm = isPowerOn ? parseInt(rpmSlider.value) : 0; const temp = isPowerOn ? parseInt(tempSlider.value) : 20; const liqColor = colorPicker.value; rpmDisplay.innerText = rpm; tempDisplay.innerText = temp; if (isPowerOn && audioCtx) { if (rpm > 0) { motorOsc.frequency.setTargetAtTime(120 + (rpm * 0.1), audioCtx.currentTime, 0.1); motorGain.gain.setTargetAtTime(0.03 + (rpm / 5000), audioCtx.currentTime, 0.1); } else { motorGain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.1); } } const centerX = canvas.width / 2; const centerY = 450; const beakerW = 220; const beakerH = 300; const beakerTopY = centerY - beakerH; const baseWaterLevelY = beakerTopY + beakerH * 0.2; // 脱調と慣性 if (rpm > 1200 && Math.random() < (rpm - 1100) / 1500) isOutOfSync = true; if (rpm < 400) isOutOfSync = false; if (isOutOfSync) { angle += (Math.random() - 0.5) * 2; shakeX = (Math.random() - 0.5) * 18; shakeY = (Math.random() - 0.5) * 6; warningText.innerText = "⚠️ DANGER: 脱調発生!"; if (Math.random() > 0.4) playClackSound(); effectiveRpm += (0 - effectiveRpm) * 0.03; } else { angle += (rpm / 1000); shakeX = 0; shakeY = 0; warningText.innerText = ""; effectiveRpm += (rpm - effectiveRpm) * 0.05; } const maxDepth = centerY - baseWaterLevelY - 15; const vortexDepth = Math.min((effectiveRpm / 1500) * maxDepth, maxDepth); const edgeWaterLevelY = baseWaterLevelY - (vortexDepth * 0.35); // 湯気生成と描画 if (temp > 40 && Math.random() < (temp - 30) / 100) { steamParticles.push({ x: centerX + (Math.random() - 0.5) * (beakerW - 40), y: edgeWaterLevelY - 10, vx: (Math.random() - 0.5) * 0.3, vy: -1.5 - Math.random() * (temp / 40), size: 15 + Math.random() * 25, alpha: 0.2 + Math.random() * 0.3 }); } steamParticles.forEach((p, index) => { const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size); gradient.addColorStop(0, `rgba(255, 255, 255, ${p.alpha})`); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); p.x += p.vx; p.y += p.vy; p.size += 0.3; p.alpha -= 0.006; if (p.alpha <= 0) steamParticles.splice(index, 1); }); // 1. ビーカー背面(口の部分) ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(centerX, beakerTopY, beakerW/2, 15, 0, 0, Math.PI * 2); ctx.stroke(); // ========================================== // 2. 液体レイヤーの描画(3D表現) // ========================================== // 【レイヤーA】奥側の液体と渦(少し不透明度を高めに設定) ctx.fillStyle = hexToRgba(liqColor, 0.6); ctx.beginPath(); ctx.moveTo(centerX - beakerW/2 + 4, edgeWaterLevelY); ctx.bezierCurveTo( centerX - beakerW/4, edgeWaterLevelY, centerX - 15, baseWaterLevelY + vortexDepth, centerX, baseWaterLevelY + vortexDepth ); ctx.bezierCurveTo( centerX + 15, baseWaterLevelY + vortexDepth, centerX + beakerW/4, edgeWaterLevelY, centerX + beakerW/2 - 4, edgeWaterLevelY ); ctx.lineTo(centerX + beakerW/2 - 4, centerY); ctx.quadraticCurveTo(centerX, centerY + 25, centerX - beakerW/2 + 4, centerY); ctx.closePath(); ctx.fill(); // 【レイヤーB】手前側の液体のボリューム(半透明で奥を透かせる) ctx.fillStyle = hexToRgba(liqColor, 0.35); // 透け感を出す ctx.beginPath(); ctx.moveTo(centerX - beakerW/2 + 4, edgeWaterLevelY); // 手前側の液面のフチ(下に少し膨らむカーブで円柱の前面を表現) ctx.quadraticCurveTo(centerX, edgeWaterLevelY + 18, centerX + beakerW/2 - 4, edgeWaterLevelY); // そのまま底面まで描画 ctx.lineTo(centerX + beakerW/2 - 4, centerY); ctx.quadraticCurveTo(centerX, centerY + 25, centerX - beakerW/2 + 4, centerY); ctx.closePath(); ctx.fill(); // 3. ビーカーの枠とメモリ ctx.strokeStyle = "rgba(255,255,255,0.6)"; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(centerX - beakerW/2, beakerTopY); ctx.lineTo(centerX - beakerW/2, centerY); ctx.quadraticCurveTo(centerX, centerY + 30, centerX + beakerW/2, centerY); ctx.lineTo(centerX + beakerW/2, beakerTopY); ctx.stroke(); ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "bold 14px Helvetica, Arial, sans-serif"; ctx.textAlign = "right"; const refY = beakerTopY + beakerH * 0.2; const stepY = (centerY - refY) / 6; for(let i=0; i<=5; i++) { let ml = 300 - (i * 50); if (ml <= 0) break; let y = refY + (i * stepY); ctx.beginPath(); ctx.moveTo(centerX - beakerW/2, y); const lineLen = (ml % 100 === 0) ? 20 : 12; ctx.lineTo(centerX - beakerW/2 + lineLen, y); ctx.lineWidth = 2; ctx.stroke(); if(ml % 100 === 0) ctx.fillText(ml, centerX - beakerW/2 + 50, y + 5); } ctx.font = "12px Helvetica, Arial, sans-serif"; ctx.fillText("APPROX.", centerX - beakerW/2 + 65, refY - 20); ctx.fillText("300 ml", centerX - beakerW/2 + 65, refY - 5); // 4. 攪拌子(オーバル型 / フットボール型) ctx.save(); ctx.translate(centerX + shakeX, centerY - 8 + shakeY); const magLen = 45; const magThickness = 16; const tilt = Math.cos(angle); ctx.fillStyle = "#eee"; ctx.beginPath(); // 角丸の四角形ではなく、楕円(ellipse)を描画してオーバル型を表現 ctx.ellipse(0, 0, (magLen/2) * Math.abs(tilt), magThickness/2, 0, 0, Math.PI*2); ctx.fill(); ctx.restore(); // 5. ホットスターラーベース const baseColor = (temp > 50) ? `rgb(${150 + temp/2}, 160, 160)` : "#bdc3c7"; ctx.fillStyle = baseColor; ctx.beginPath(); ctx.roundRect(centerX - 130, centerY + 25, 260, 50, 8); ctx.fill(); ctx.fillStyle = (temp > 40) ? `rgba(255, 60, 20, ${(temp-20)/160})` : "#7f8c8d"; ctx.beginPath(); ctx.ellipse(centerX, centerY + 25, 120, 12, 0, 0, Math.PI*2); ctx.fill(); requestAnimationFrame(draw);}draw();</script></body></html>
■ 今日の学び
今回は、ゲームっぽくないものができました。(^^;)
このブログのタイトルにも“実験室”と付いているとおり、
実験は大好きです。🔬
どんな結果が出るのか分からない実験ほど楽しいです。
想像と違ったりすることがあると、
その結果につながる原因を考えますが、
きっとその積み重ねに意味があるんだと感じています。
今回のゲーム?も、宝物の一つです。(^v^*)
■ 次回は、少しのミスが引き金になってしまう、
「ドキドキハラハラ」なゲーム。
集中力と、精密な操作がポイントになります。
安心してください。ファンタジーな世界観に戻ります♪
お楽しみに。(*^v^)ノシ
次の実験はこちら(魔力増幅・ドラゴン退治):
「ドミノ・ドラゴン」
ゲーム作りが楽しくて、どんどん作品が増えています(*^v^*)
もし応援していただけたら、とても励みになります。






🌈はじめての方へ
🌳 実験一覧
🌱 このブログについて
🍃 プライバシーポリシー
🐣未記事ゲーム展示室