States of Matter
by ArcadeLab793 lines25.3 KB
<!--ARCADELAB
title: States of Matter
description: A heat-it-up sandbox — slide from cold to hot, watch the temperature number climb, see particles melt and boil, and tap inside the box to add heat right where you touch.
emoji: 🔥
color: orange
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>States of Matter</title>
<style>
:root {
--bg: #140a06;
--panel: rgba(46, 26, 14, 0.86);
--accent: #ffa94d;
--accent2: #4dabff;
--text: #fff1e6;
--muted: #e0b894;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text);
-webkit-tap-highlight-color: transparent;
}
#stage { position: fixed; inset: 0; }
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#hud {
position: fixed;
top: 0; left: 0; right: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: clamp(17px, 4.6vw, 22px);
background: var(--panel);
border: 1px solid rgba(255, 169, 77, 0.32);
border-radius: 14px;
padding: 8px 13px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.title .em { font-size: 1.15em; }
.spacer { flex: 1 1 auto; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; pointer-events: auto; }
button {
pointer-events: auto;
font: inherit;
font-size: clamp(14px, 3.6vw, 16px);
font-weight: 600;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(255, 169, 77, 0.36);
border-radius: 13px;
padding: 11px 16px;
min-height: 44px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform .12s ease, background .18s ease, border-color .18s ease;
}
button:hover { background: rgba(78, 44, 22, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
#dock {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
#sliderWrap {
pointer-events: auto;
background: var(--panel);
border: 1px solid rgba(255, 169, 77, 0.28);
border-radius: 16px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
max-width: 94vw;
width: 460px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#sliderWrap .end {
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
white-space: nowrap;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
flex: 1 1 auto;
height: 44px;
background: transparent;
cursor: pointer;
margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #4dabff, #b0b8c8, #ffd166, #ff6a3d);
}
input[type=range]::-moz-range-track {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #4dabff, #b0b8c8, #ffd166, #ff6a3d);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 32px;
height: 32px;
margin-top: -10px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffffff, #ffd9a8 60%, #ffa94d);
border: 3px solid #2a160a;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
input[type=range]::-moz-range-thumb {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffffff, #ffd9a8 60%, #ffa94d);
border: 3px solid #2a160a;
}
#hint {
pointer-events: none;
background: var(--panel);
border: 1px solid rgba(255, 169, 77, 0.24);
border-radius: 13px;
padding: 8px 14px;
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
text-align: center;
max-width: 92vw;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#howBtn {
position: fixed;
z-index: 6;
right: clamp(10px, 2.4vw, 18px);
bottom: calc(clamp(10px, 2.4vw, 18px) + 132px);
}
#howPanel {
position: fixed;
z-index: 7;
right: clamp(10px, 2.4vw, 18px);
bottom: calc(clamp(10px, 2.4vw, 18px) + 184px);
width: min(320px, 90vw);
background: var(--panel);
border: 1px solid rgba(255, 169, 77, 0.36);
border-radius: 16px;
padding: 16px 18px;
color: var(--text);
font-size: 16px;
line-height: 1.5;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
display: none;
}
#howPanel.open { display: block; }
#howPanel h3 { margin: 0 0 8px; font-size: 18px; }
#howPanel p { margin: 0 0 8px; color: var(--muted); }
#howPanel p:last-child { margin-bottom: 0; }
#stage { opacity: 0; transition: opacity .8s ease; }
#stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
</div>
<div id="hud">
<div class="title"><span class="em">🔥</span><span>States of Matter</span></div>
<div class="spacer"></div>
<div class="controls">
<button id="cool" type="button">❄️ Cool</button>
<button id="heat" type="button">🔥 Heat</button>
</div>
</div>
<button id="howBtn" type="button">💡 How it works</button>
<div id="howPanel">
<h3>🔥 How it works</h3>
<p>Heat gives tiny particles energy to move.</p>
<p><b>Cold:</b> they hold still in a neat grid — a solid.</p>
<p><b>Warm:</b> they slide past each other — a liquid.</p>
<p><b>Hot:</b> they zoom everywhere and bounce — a gas!</p>
<p>Watch the thermometer number. Going up: <b>melting</b> then <b>boiling</b>. Going down: <b>condensing</b> then <b>freezing</b>.</p>
<p>Tap inside the box to add a splash of heat right where you touch! 👆</p>
</div>
<div id="dock">
<div id="hint">Slide to add heat 🔥 — or tap inside the box! 👆</div>
<div id="sliderWrap">
<span class="end">❄️ Cold</span>
<input id="heatSlider" type="range" min="0" max="1000" value="120" aria-label="Temperature">
<span class="end">Hot 🔥</span>
</div>
</div>
<script>
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var slider = document.getElementById("heatSlider");
var heatBtn = document.getElementById("heat");
var coolBtn = document.getElementById("cool");
var howBtn = document.getElementById("howBtn");
var howPanel = document.getElementById("howPanel");
var W = 0, H = 0, DPR = 1;
var SLIDER_MAX = 1000;
// heat: 0 (cold) .. 1 (hot). targetHeat follows the slider; heat eases toward it.
var targetHeat = parseFloat(slider.value) / SLIDER_MAX;
var heat = targetHeat;
// the box that contains the particles
var box = { x: 0, y: 0, w: 0, h: 0 };
// transition banner: shows "Melting!" etc. for a short time
var lastState = ""; // last settled state name
var transitionText = ""; // current transition label
var transitionTimer = 0; // seconds remaining to show it
var prevHeat = 0; // heat last frame, to know direction
// tap ripples: visual feedback for local heat taps
var ripples = [];
// temperature display range (kid-friendly Celsius-ish numbers)
var TEMP_MIN = -20, TEMP_MAX = 320;
function tempFromHeat(h) {
return Math.round(TEMP_MIN + clamp(0, h, 1) * (TEMP_MAX - TEMP_MIN));
}
var COLS = 10, ROWS = 10; // lattice grid -> 100 particles
var particles = [];
var spacing = 1; // lattice spacing
var pr = 6; // particle radius
var thermoGap = 90; // horizontal space reserved for thermometer
function rand(a, b) { return a + Math.random() * (b - a); }
function computeBox() {
var pad = clamp(16, Math.min(W, H) * 0.06, 60);
var topGap = clamp(96, H * 0.15, 150); // room for header
var botGap = clamp(150, H * 0.26, 220); // room for slider + thermometer
// space reserved to the right of the box for the thermometer
thermoGap = clamp(70, W * 0.12, 120);
var availW = W - pad * 2 - thermoGap; // leave room for thermometer
var availH = H - topGap - botGap;
var side = Math.max(180, Math.min(availW, availH));
box.w = side;
box.h = side;
// centre the box + thermometer cluster horizontally
var clusterW = side + thermoGap;
box.x = Math.max(pad, (W - clusterW) / 2);
box.y = topGap + Math.max(0, (availH - side) / 2);
// recompute lattice geometry
spacing = Math.min(box.w, box.h) / (COLS + 1);
pr = Math.max(4, spacing * 0.34);
}
// lattice "home" position for particle index i
function homePos(i) {
var col = i % COLS;
var row = Math.floor(i / COLS);
var gw = spacing * (COLS - 1);
var gh = spacing * (ROWS - 1);
var ox = box.x + (box.w - gw) / 2;
// lattice anchored toward the bottom of the box (solids/liquids sit low)
var oy = box.y + box.h - gh - spacing * 0.6;
return { x: ox + col * spacing, y: oy + row * spacing };
}
function initParticles() {
particles = [];
for (var i = 0; i < COLS * ROWS; i++) {
var h = homePos(i);
particles.push({
x: h.x + rand(-2, 2),
y: h.y + rand(-2, 2),
vx: rand(-8, 8),
vy: rand(-8, 8),
phase: rand(0, Math.PI * 2), // for vibration variety
boost: 0 // local heat from a tap; decays over time
});
}
}
function resize() {
var rect = stage.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
computeBox();
if (particles.length === 0) initParticles();
}
function clamp(lo, v, hi) { return Math.max(lo, Math.min(hi, v)); }
// state name from heat
function stateName(h) {
if (h < 0.34) return "Solid";
if (h < 0.66) return "Liquid";
return "Gas";
}
// ---- physics ----
function step(dt, time) {
prevHeat = heat;
// ease heat toward target
heat += (targetHeat - heat) * Math.min(1, dt * 2.4);
// detect a state change and name the transition
var curState = stateName(heat);
if (curState !== lastState) {
if (lastState !== "") {
var goingUp = heat > prevHeat;
if (curState === "Liquid" && lastState === "Solid") transitionText = "🫠 Melting!";
else if (curState === "Solid" && lastState === "Liquid") transitionText = "❄️ Freezing!";
else if (curState === "Gas" && lastState === "Liquid") transitionText = "♨️ Boiling!";
else if (curState === "Liquid" && lastState === "Gas") transitionText = "💧 Condensing!";
else transitionText = goingUp ? "♨️ Heating up!" : "❄️ Cooling down!";
transitionTimer = 2.2;
}
lastState = curState;
}
if (transitionTimer > 0) transitionTimer = Math.max(0, transitionTimer - dt);
var n = particles.length;
// regime weights
// solidW: 1 at heat 0 -> 0 at heat ~0.4
var solidW = clamp(0, (0.42 - heat) / 0.42, 1);
// gasW: 0 until ~0.5 -> fully gas by heat 0.66, so the particles look
// like a real gas (filling the box) as soon as the label says "Gas".
var gasW = clamp(0, (heat - 0.5) / 0.16, 1);
// liquidW: the middle
var liquidW = clamp(0, 1 - solidW - gasW, 1);
// overall thermal speed scale
var thermal = 14 + heat * heat * 460;
var i, p;
// pairwise interactions: keep particles apart; in liquid they cohere mildly.
// O(n^2) with n=100 is fine.
var collideDist = pr * 2.05;
var cohereDist = pr * 3.4;
for (i = 0; i < n; i++) {
p = particles[i];
for (var j = i + 1; j < n; j++) {
var q = particles[j];
var dx = q.x - p.x;
var dy = q.y - p.y;
var d2 = dx * dx + dy * dy;
if (d2 < 0.0001) { dx = rand(-1, 1); dy = rand(-1, 1); d2 = dx * dx + dy * dy; }
var d = Math.sqrt(d2);
if (d < collideDist) {
// soft separation
var overlap = (collideDist - d);
var nx = dx / d, ny = dy / d;
var push = overlap * 0.5;
p.x -= nx * push; p.y -= ny * push;
q.x += nx * push; q.y += ny * push;
// exchange a little velocity
var rvx = q.vx - p.vx, rvy = q.vy - p.vy;
var sep = rvx * nx + rvy * ny;
if (sep < 0) {
var imp = sep * 0.5;
p.vx += nx * imp; p.vy += ny * imp;
q.vx -= nx * imp; q.vy -= ny * imp;
}
} else if (liquidW > 0.01 && d < cohereDist) {
// gentle cohesion so liquid stays as a blob
var cn = liquidW * 30 * (1 - d / cohereDist);
var ux = dx / d, uy = dy / d;
p.vx += ux * cn * dt;
p.vy += uy * cn * dt;
q.vx -= ux * cn * dt;
q.vy -= uy * cn * dt;
}
}
}
for (i = 0; i < n; i++) {
p = particles[i];
var home = homePos(i);
// --- solid behaviour: spring back to lattice home, vibrate ---
if (solidW > 0.001) {
var k = 60 * solidW;
p.vx += (home.x - p.x) * k * dt;
p.vy += (home.y - p.y) * k * dt;
// tiny vibration jitter
var vib = solidW * (4 + heat * 40);
p.vx += Math.cos(time * 0.012 + p.phase) * vib * dt * 6;
p.vy += Math.sin(time * 0.013 + p.phase * 1.7) * vib * dt * 6;
}
// --- gravity in liquid regime so it settles at the bottom ---
var grav = liquidW * 260 + solidW * 0 + 0;
p.vy += grav * dt;
// --- random thermal kicks (more when hot) ---
var kick = thermal * (0.4 + gasW * 0.6);
// local heat from a tap adds an extra burst to this particle
if (p.boost > 0.001) {
kick += p.boost * 900;
// a tap also nudges the particle loose from its lattice spot
if (solidW > 0.001) {
p.vx += rand(-1, 1) * p.boost * 260 * dt;
p.vy += rand(-1, 1) * p.boost * 260 * dt;
}
p.boost = Math.max(0, p.boost - dt * 0.9);
}
p.vx += rand(-1, 1) * kick * dt;
p.vy += rand(-1, 1) * kick * dt;
// --- damping: strong when cold/liquid, light when gas ---
var damp = 1 - (0.9 - gasW * 0.86) * dt;
damp = clamp(0, damp, 1);
p.vx *= damp;
p.vy *= damp;
// --- speed cap so things stay stable ---
var spd2 = p.vx * p.vx + p.vy * p.vy;
var maxSpd = 60 + heat * 620 + p.boost * 420;
if (spd2 > maxSpd * maxSpd) {
var s = maxSpd / Math.sqrt(spd2);
p.vx *= s; p.vy *= s;
}
// --- integrate ---
p.x += p.vx * dt;
p.y += p.vy * dt;
// --- walls ---
var minX = box.x + pr, maxX = box.x + box.w - pr;
var minY = box.y + pr, maxY = box.y + box.h - pr;
if (p.x < minX) { p.x = minX; p.vx = Math.abs(p.vx) * 0.86; }
else if (p.x > maxX) { p.x = maxX; p.vx = -Math.abs(p.vx) * 0.86; }
if (p.y < minY) { p.y = minY; p.vy = Math.abs(p.vy) * 0.86; }
else if (p.y > maxY) { p.y = maxY; p.vy = -Math.abs(p.vy) * 0.86; }
}
}
// ---- particle colour from heat + individual speed ----
function particleColor(p) {
var spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
// base hue: 210 (cool blue) -> 22 (hot orange-red)
var t = clamp(0, heat * 0.7 + Math.min(spd / 420, 1) * 0.3, 1);
var hue = 210 - t * 192; // 210 -> 18
var light = 52 + t * 16;
return "hsl(" + hue.toFixed(0) + ",90%," + light.toFixed(0) + "%)";
}
// ---- draw ----
function draw(time) {
// background
var topCol = "#1a0e07";
var bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, topCol);
bg.addColorStop(1, "#0c0703");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// ambient heat glow from below when hot
if (heat > 0.4) {
var glow = ctx.createRadialGradient(W / 2, H, 0, W / 2, H, H * 0.9);
glow.addColorStop(0, "rgba(255,90,30," + ((heat - 0.4) * 0.5).toFixed(3) + ")");
glow.addColorStop(1, "rgba(255,90,30,0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
}
// ---- the box ----
var floorGlow = clamp(0, heat, 1);
// box interior
roundRect(box.x, box.y, box.w, box.h, 16);
var inside = ctx.createLinearGradient(0, box.y, 0, box.y + box.h);
inside.addColorStop(0, "rgba(255,255,255,0.04)");
inside.addColorStop(1, "rgba(0,0,0,0.25)");
ctx.fillStyle = inside;
ctx.fill();
// hot floor strip
ctx.save();
roundRect(box.x, box.y, box.w, box.h, 16);
ctx.clip();
var floorH = box.h * 0.16;
var fl = ctx.createLinearGradient(0, box.y + box.h - floorH, 0, box.y + box.h);
fl.addColorStop(0, "rgba(255,120,40,0)");
fl.addColorStop(1, "rgba(255,120,40," + (0.15 + floorGlow * 0.55).toFixed(3) + ")");
ctx.fillStyle = fl;
ctx.fillRect(box.x, box.y + box.h - floorH, box.w, floorH);
ctx.restore();
// box border
roundRect(box.x, box.y, box.w, box.h, 16);
ctx.strokeStyle = "rgba(255,169,77,0.7)";
ctx.lineWidth = 3;
ctx.stroke();
// ---- particles ----
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var col = particleColor(p);
// glow for hot particles
if (heat > 0.45) {
var gl = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, pr * 3);
gl.addColorStop(0, "hsla(24,100%,60%," + ((heat - 0.45) * 0.5).toFixed(3) + ")");
gl.addColorStop(1, "hsla(24,100%,60%,0)");
ctx.fillStyle = gl;
ctx.beginPath();
ctx.arc(p.x, p.y, pr * 3, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = col;
ctx.beginPath();
ctx.arc(p.x, p.y, pr, 0, Math.PI * 2);
ctx.fill();
// highlight
ctx.fillStyle = "rgba(255,255,255,0.75)";
ctx.beginPath();
ctx.arc(p.x - pr * 0.32, p.y - pr * 0.32, pr * 0.36, 0, Math.PI * 2);
ctx.fill();
}
// ---- tap ripples (heat splashes) ----
for (var ri = 0; ri < ripples.length; ri++) {
var rp = ripples[ri];
var k = 1 - rp.life / rp.maxLife; // 0 -> 1 as it expands
var rad = pr * 1.5 + k * pr * 6;
ctx.save();
ctx.globalAlpha = (1 - k) * 0.55;
ctx.strokeStyle = "#ff9434";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(rp.x, rp.y, rad, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// ---- thermometer ----
drawThermometer();
// ---- state label ----
var name = stateName(heat);
var emoji = name === "Solid" ? "🧊" : (name === "Liquid" ? "💧" : "💨");
ctx.textAlign = "center";
ctx.fillStyle = "#fff1e6";
ctx.font = "700 " + clamp(20, box.w * 0.11, 40) + "px system-ui, sans-serif";
var labelY = box.y - clamp(10, H * 0.02, 18);
ctx.fillText(emoji + " " + name, box.x + box.w / 2, labelY);
// ---- transition banner (melting / boiling / etc.) ----
if (transitionTimer > 0 && transitionText) {
var fade = Math.min(1, transitionTimer / 0.5); // fade out at the end
var tFs = clamp(18, box.w * 0.085, 32);
ctx.font = "800 " + tFs + "px system-ui, sans-serif";
var ttw = ctx.measureText(transitionText).width;
var tbW = Math.min(W - 16, ttw + 36);
var tbH = tFs + 18;
var tbX = box.x + box.w / 2 - tbW / 2;
if (tbX < 8) tbX = 8;
if (tbX + tbW > W - 8) tbX = W - 8 - tbW;
var tbY = box.y + box.h * 0.5 - tbH / 2;
ctx.save();
ctx.globalAlpha = fade;
roundRect(tbX, tbY, tbW, tbH, tbH / 2);
ctx.fillStyle = "rgba(20,10,4,0.9)";
ctx.fill();
ctx.strokeStyle = "rgba(255,169,77,0.85)";
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.fillStyle = "#ffd166";
ctx.textBaseline = "middle";
ctx.fillText(transitionText, tbX + tbW / 2, tbY + tbH / 2 + 1);
ctx.textBaseline = "alphabetic";
ctx.restore();
}
ctx.textAlign = "left";
}
function drawThermometer() {
var tw = clamp(26, W * 0.05, 40); // bulb/tube width
// centre the thermometer inside the reserved gap beside the box
var tx = box.x + box.w + (thermoGap - tw) / 2;
if (tx + tw > W - 10) tx = W - 10 - tw; // keep on screen
if (tx < box.x + box.w + 6) tx = box.x + box.w + 6;
var tubeTop = box.y + box.h * 0.04;
var tubeBot = box.y + box.h * 0.86;
var bulbR = tw * 0.85;
var bulbY = tubeBot + bulbR * 0.6;
var tubeH = tubeBot - tubeTop;
// tube background
ctx.lineCap = "round";
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = tw;
ctx.beginPath();
ctx.moveTo(tx + tw / 2, tubeTop);
ctx.lineTo(tx + tw / 2, tubeBot);
ctx.stroke();
// bulb background
ctx.fillStyle = "rgba(255,255,255,0.16)";
ctx.beginPath();
ctx.arc(tx + tw / 2, bulbY, bulbR, 0, Math.PI * 2);
ctx.fill();
// mercury fill
var fillTop = tubeBot - heat * tubeH;
var hue = 210 - heat * 192;
var merc = "hsl(" + hue.toFixed(0) + ",92%,56%)";
ctx.strokeStyle = merc;
ctx.lineWidth = tw * 0.56;
ctx.beginPath();
ctx.moveTo(tx + tw / 2, fillTop);
ctx.lineTo(tx + tw / 2, bulbY);
ctx.stroke();
ctx.fillStyle = merc;
ctx.beginPath();
ctx.arc(tx + tw / 2, bulbY, bulbR * 0.78, 0, Math.PI * 2);
ctx.fill();
// tick marks
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = 2;
for (var t = 0; t <= 4; t++) {
var ty = tubeBot - (t / 4) * tubeH;
ctx.beginPath();
ctx.moveTo(tx - 4, ty);
ctx.lineTo(tx + 2, ty);
ctx.stroke();
}
// temperature number, in a little pill above the thermometer
var temp = tempFromHeat(heat);
var label = temp + "°";
var fs = clamp(15, tw * 0.62, 24);
ctx.font = "800 " + fs + "px system-ui, sans-serif";
var lw = ctx.measureText(label).width;
var pillW = lw + 16;
var pillH = fs + 12;
var pillX = tx + tw / 2 - pillW / 2;
if (pillX + pillW > W - 4) pillX = W - 4 - pillW;
if (pillX < 4) pillX = 4;
var pillY = tubeTop - pillH - 6;
if (pillY < box.y - pillH - 2) pillY = box.y - pillH - 2;
roundRect(pillX, pillY, pillW, pillH, pillH / 2);
ctx.fillStyle = "rgba(20,10,4,0.92)";
ctx.fill();
ctx.strokeStyle = merc;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "#fff1e6";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, pillX + pillW / 2, pillY + pillH / 2 + 1);
ctx.textBaseline = "alphabetic";
ctx.textAlign = "left";
}
function roundRect(x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// ---- main loop ----
var lastTime = 0;
function frame(time) {
var dt = lastTime ? Math.min((time - lastTime) / 1000, 0.04) : 0.016;
lastTime = time;
// small fixed substeps for stability
var steps = 2;
for (var s = 0; s < steps; s++) {
step(dt / steps, time);
}
// age tap ripples
for (var ri = ripples.length - 1; ri >= 0; ri--) {
ripples[ri].life += dt;
if (ripples[ri].life >= ripples[ri].maxLife) ripples.splice(ri, 1);
}
draw(time);
requestAnimationFrame(frame);
}
// ---- input ----
slider.addEventListener("input", function () {
targetHeat = parseFloat(slider.value) / SLIDER_MAX;
});
// tap (or drag) inside the box to add a splash of local heat
function addHeatAt(px, py) {
// ignore taps outside the box
if (px < box.x || px > box.x + box.w || py < box.y || py > box.y + box.h) return;
ripples.push({ x: px, y: py, life: 0, maxLife: 0.6 });
if (ripples.length > 24) ripples.shift();
var radius = Math.max(box.w * 0.22, 60);
var r2 = radius * radius;
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
var dx = p.x - px, dy = p.y - py;
var d2 = dx * dx + dy * dy;
if (d2 < r2) {
var falloff = 1 - Math.sqrt(d2) / radius; // 1 at centre -> 0 at edge
p.boost = Math.min(1.4, p.boost + falloff * 0.9);
}
}
}
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
var rect = canvas.getBoundingClientRect();
addHeatAt(e.clientX - rect.left, e.clientY - rect.top);
});
function nudge(delta) {
var v = clamp(0, parseFloat(slider.value) + delta, SLIDER_MAX);
slider.value = String(v);
targetHeat = v / SLIDER_MAX;
}
heatBtn.addEventListener("click", function () { nudge(SLIDER_MAX * 0.22); });
coolBtn.addEventListener("click", function () { nudge(-SLIDER_MAX * 0.22); });
howBtn.addEventListener("click", function () {
howPanel.classList.toggle("open");
});
// ---- boot ----
window.addEventListener("resize", function () {
resize();
});
resize();
initParticles();
requestAnimationFrame(frame);
requestAnimationFrame(function () {
stage.classList.add("ready");
});
})();
</script>
</body>
</html>
Game Source: States of Matter
Creator: ArcadeLab
Libraries: none
Complexity: complex (793 lines, 25.3 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: states-of-matter-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.