実験#25:磁力 × 渦 × 加温による撹拌(ホットスターラーシミュレーター)

■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 にします。(クリックで切り替え)
・スピードと温度をマウスで調整してください。
 ※脱調した際は回転を下げると安定します。
・お好みで液体の色を変えることもできます。

 コードといっても難しい部分はありません。
 貼り付けて保存するだけで動きます。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<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^*)
 もし応援していただけたら、とても励みになります。

~このブログについて~

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

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


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

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

続きを読む