Steam Engine
by ArcadeLab1311 lines42.3 KB
<!--ARCADELAB
title: Steam Engine
description: A see-through steam engine — turn up the fire to boil water, watch the pressure gauge climb, spin the giant flywheel and its governor balls, and tap the engine for a steam whistle.
emoji: 🚂
color: red
-->
<!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>Steam Engine</title>
<style>
:root {
--bg: #160a0a;
--panel: rgba(46, 18, 18, 0.88);
--accent: #ff6b4d;
--accent2: #ffb33c;
--text: #ffeede;
--muted: #f0b9a8;
}
* { 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, 107, 77, 0.34);
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(13px, 3.6vw, 15px);
font-weight: 600;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(255, 107, 77, 0.4);
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(74, 28, 28, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
#info {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
#heatBox {
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
background: var(--panel);
border: 1px solid rgba(255, 107, 77, 0.34);
border-radius: 15px;
padding: 12px 18px;
width: min(460px, 92vw);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#heatBox .lbl {
font-size: clamp(13px, 3.6vw, 16px);
font-weight: 700;
color: var(--accent2);
white-space: nowrap;
}
#heat {
-webkit-appearance: none;
appearance: none;
flex: 1 1 auto;
height: 16px;
border-radius: 10px;
background: linear-gradient(90deg, #3a2222 0%, #ff7a3c 60%, #ffd24d 100%);
outline: none;
margin: 0;
}
#heat::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px; height: 34px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #ff6b4d);
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
cursor: pointer;
}
#heat::-moz-range-thumb {
width: 34px; height: 34px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #ff6b4d);
border: 3px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
cursor: pointer;
}
#heatVal {
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--accent2);
min-width: 3ch;
text-align: right;
}
#hint {
background: var(--panel);
border: 1px solid rgba(255, 107, 77, 0.22);
border-radius: 13px;
padding: 7px 14px;
font-size: clamp(12px, 3.3vw, 14px);
color: var(--muted);
text-align: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 92vw;
}
#howPanel {
position: fixed;
left: 50%;
bottom: clamp(150px, 30vw, 178px);
transform: translateX(-50%) translateY(12px);
width: min(420px, 90vw);
background: var(--panel);
border: 1px solid rgba(255, 107, 77, 0.44);
border-radius: 16px;
padding: 16px 18px;
z-index: 6;
opacity: 0;
pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#howPanel.open { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
#howPanel h2 { margin: 0 0 8px; font-size: clamp(15px, 4vw, 18px); color: var(--accent); }
#howPanel p { margin: 6px 0; font-size: clamp(13px, 3.6vw, 15px); line-height: 1.5; }
#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>Steam Engine</span></div>
<div class="spacer"></div>
<div class="controls">
<button id="how" type="button">How it works</button>
</div>
</div>
<div id="howPanel">
<h2>How it works</h2>
<p>The fire heats water in the boiler until it turns into steam.</p>
<p>Steam takes up lots of room, so it pushes the piston along the cylinder — that's the "PUSH" stroke. The flywheel carries it back on the "RETURN" stroke.</p>
<p>The piston turns the crank, spinning the heavy flywheel round and round.</p>
<p>The pressure gauge climbs as the boiler heats up, and the spinning governor balls fly outward when the engine runs fast.</p>
<p>More fire makes more steam — and a faster flywheel! Tap the engine to blow the steam whistle.</p>
</div>
<div id="info">
<div id="heatBox">
<span class="lbl">🔥 Heat</span>
<input id="heat" type="range" min="0" max="100" value="55" step="1" aria-label="Heat">
<span id="heatVal">55</span>
</div>
<div id="hint">Tap the engine to blow the steam whistle 📣</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 heatSlider = document.getElementById("heat");
var heatVal = document.getElementById("heatVal");
var W = 0, H = 0, DPR = 1;
var heat = 55; // 0..100
var crankAngle = 0; // radians
var crankSpeed = 0; // radians/sec
var pressure = 0; // 0..100, eases up toward heat (boiler takes time to build)
var govAngle = 0; // governor spin angle (radians)
// particle pools
var steamParticles = [];
var fireParticles = [];
var emberParticles = [];
function rand(a, b) { return a + Math.random() * (b - a); }
// ---- layout: all geometry computed from window size into one object ----
var L = {};
function layout() {
// Engine fits inside a centered region. We design in a virtual frame and
// place it. Reserve top space for HUD and bottom for the heat slider.
var topPad = 84;
var botPad = 188;
var availH = Math.max(120, H - topPad - botPad);
var availW = W * 0.94;
// The engine drawing has a natural aspect ratio ~ 1.55 wide : 1 tall
var aspect = 1.62;
var ew = Math.min(availW, availH * aspect);
var eh = ew / aspect;
if (eh > availH) { eh = availH; ew = eh * aspect; }
var ox = (W - ew) / 2;
var oy = topPad + (availH - eh) / 2;
L.ox = ox; L.oy = oy; L.ew = ew; L.eh = eh;
// --- boiler (left-center) ---
L.boiler = {
x: ox + ew * 0.05,
y: oy + eh * 0.12,
w: ew * 0.42,
h: eh * 0.62
};
// fire box under boiler
L.fire = {
x: L.boiler.x + L.boiler.w * 0.12,
y: L.boiler.y + L.boiler.h,
w: L.boiler.w * 0.76,
h: eh * 0.22
};
// water level inside boiler (lower half)
L.waterTop = L.boiler.y + L.boiler.h * 0.46;
// --- steam pipe from boiler top to cylinder ---
// --- cylinder (right side, horizontal) ---
var cylH = eh * 0.26;
L.cylinder = {
x: L.boiler.x + L.boiler.w + ew * 0.04,
y: oy + eh * 0.16,
w: ew * 0.40,
h: cylH
};
// piston travels inside cylinder
L.pistonW = L.cylinder.w * 0.16;
L.strokeMin = L.cylinder.x + L.pistonW * 0.5 + 4; // leftmost piston center
L.strokeMax = L.cylinder.x + L.cylinder.w - L.pistonW - 8; // rightmost piston center
L.pistonY = L.cylinder.y + L.cylinder.h / 2;
// --- flywheel (far right) ---
var flyR = Math.min(eh * 0.34, ew * 0.17);
L.fly = {
cx: L.cylinder.x + L.cylinder.w + ew * 0.02 + flyR,
cy: L.pistonY,
r: flyR
};
// crank pin radius = half the piston stroke
L.crankR = (L.strokeMax - L.strokeMin) / 2;
if (L.crankR > flyR * 0.62) L.crankR = flyR * 0.62;
// make sure flywheel fits on screen
if (L.fly.cx + flyR > W - 8) {
var over = (L.fly.cx + flyR) - (W - 8);
L.fly.cx -= over;
}
// --- pressure gauge: a round dial mounted on top of the boiler ---
var bo = L.boiler, cy2 = L.cylinder;
var gaugeR = Math.min(eh * 0.13, ew * 0.075, bo.w * 0.26);
gaugeR = Math.max(gaugeR, 22);
L.gauge = {
cx: bo.x + bo.w * 0.32,
cy: bo.y - gaugeR - 6,
r: gaugeR
};
if (L.gauge.cy - gaugeR < topPad - 2) {
L.gauge.cy = topPad - 2 + gaugeR;
}
// --- governor: two spinning balls above the cylinder/crank area ---
var govR = Math.min(eh * 0.12, ew * 0.07);
govR = Math.max(govR, 18);
L.gov = {
cx: cy2.x + cy2.w * 0.5,
cy: cy2.y - govR * 1.5 - 8,
r: govR, // arm length
ballR: Math.max(6, govR * 0.32)
};
if (L.gov.cy - govR * 1.7 < topPad - 2) {
L.gov.cy = topPad - 2 + govR * 1.7;
}
// --- steam whistle: a little brass whistle on the boiler top ---
L.whistle = {
x: bo.x + bo.w * 0.66,
y: bo.y + 2,
h: Math.max(14, eh * 0.10)
};
}
// ---- piston kinematics ----
// Crank pin at angle a: pinX = fly.cx + crankR*cos(a)
// Piston center X follows a slider-crank. Rod length chosen long for smoothness.
function pistonXForAngle(a) {
var rodLen = L.fly.r * 1.9;
var pinX = L.fly.cx + L.crankR * Math.cos(a);
var pinY = L.fly.cy + L.crankR * Math.sin(a);
// piston moves along horizontal line y = pistonY
var dy = L.pistonY - pinY;
var horiz = Math.sqrt(Math.max(0, rodLen * rodLen - dy * dy));
return pinX - horiz;
}
// ---- resize ----
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);
layout();
}
// ---- particle spawning ----
function spawnFire() {
if (heat <= 0) return;
var n = Math.round(heat / 22);
for (var i = 0; i < n; i++) {
var fx = L.fire.x + rand(0.1, 0.9) * L.fire.w;
fireParticles.push({
x: fx,
y: L.fire.y + L.fire.h - rand(0, 6),
vx: rand(-8, 8),
vy: rand(-46, -82) * (0.5 + heat / 200),
life: 1,
decay: rand(1.4, 2.4),
size: rand(6, 14) * (0.6 + heat / 200)
});
}
if (Math.random() < heat / 240) {
emberParticles.push({
x: L.fire.x + rand(0.15, 0.85) * L.fire.w,
y: L.fire.y + L.fire.h - 4,
vx: rand(-14, 14),
vy: rand(-60, -110),
life: 1,
decay: rand(0.5, 1.0),
size: rand(1.4, 2.8)
});
}
}
function spawnSteam() {
if (heat <= 4) return;
// steam rises from water surface inside the boiler
if (Math.random() < 0.25 + heat / 130) {
steamParticles.push({
x: L.boiler.x + rand(0.2, 0.8) * L.boiler.w,
y: L.waterTop - rand(0, 8),
vx: rand(-6, 6),
vy: rand(-14, -30) * (0.4 + heat / 160),
life: 1,
decay: rand(0.35, 0.7),
size: rand(8, 18),
inBoiler: true
});
}
// a strong burst of steam from the whistle when it's blowing
if (whistlePuff > 0 && L.whistle) {
var burst = Math.round(2 + whistlePuff * 5);
for (var wb = 0; wb < burst; wb++) {
steamParticles.push({
x: L.whistle.x + rand(-3, 3),
y: L.whistle.y - rand(0, 4),
vx: rand(-26, 26),
vy: rand(-52, -96),
life: 1,
decay: rand(0.7, 1.2),
size: rand(7, 14),
inBoiler: false
});
}
}
// steam puffs out of the cylinder exhaust when the piston pushes
var pf = (Math.cos(crankAngle) + 1) / 2; // 0..1
if (heat > 8 && pf > 0.7 && Math.random() < 0.4) {
steamParticles.push({
x: L.cylinder.x + L.cylinder.w * 0.5,
y: L.cylinder.y - 4,
vx: rand(-10, 10),
vy: rand(-20, -42),
life: 1,
decay: rand(0.6, 1.1),
size: rand(6, 12),
inBoiler: false
});
}
}
function updateParticles(dt) {
var i, p;
for (i = fireParticles.length - 1; i >= 0; i--) {
p = fireParticles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy -= 30 * dt; // buoyancy
p.life -= p.decay * dt;
if (p.life <= 0) fireParticles.splice(i, 1);
}
for (i = emberParticles.length - 1; i >= 0; i--) {
p = emberParticles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += 40 * dt;
p.life -= p.decay * dt;
if (p.life <= 0) emberParticles.splice(i, 1);
}
for (i = steamParticles.length - 1; i >= 0; i--) {
p = steamParticles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy -= 8 * dt;
p.size += 10 * dt;
p.life -= p.decay * dt;
// keep boiler steam loosely inside the boiler walls
if (p.inBoiler) {
if (p.x < L.boiler.x + 6) p.x = L.boiler.x + 6;
if (p.x > L.boiler.x + L.boiler.w - 6) p.x = L.boiler.x + L.boiler.w - 6;
if (p.y < L.boiler.y + 6) p.life -= dt * 1.5;
}
if (p.life <= 0) steamParticles.splice(i, 1);
}
// cap pools
if (fireParticles.length > 240) fireParticles.splice(0, fireParticles.length - 240);
if (steamParticles.length > 200) steamParticles.splice(0, steamParticles.length - 200);
if (emberParticles.length > 80) emberParticles.splice(0, emberParticles.length - 80);
}
// ---- drawing helpers ----
function roundRect(x, y, w, h, r) {
var rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function label(text, x, y) {
ctx.font = "700 " + Math.max(11, Math.min(16, L.eh * 0.05)) + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var w = ctx.measureText(text).width;
var padX = 7, padY = 4;
ctx.fillStyle = "rgba(20,8,8,0.78)";
roundRect(x - w / 2 - padX, y - 9 - padY, w + padX * 2, 18 + padY * 2, 7);
ctx.fill();
ctx.fillStyle = "#ffeede";
ctx.fillText(text, x, y);
}
// ---- render ----
function draw(time) {
// background
var bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, "#241010");
bg.addColorStop(1, "#120707");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
var b = L.boiler, fire = L.fire, cyl = L.cylinder, fly = L.fly;
var flicker = 0.7 + 0.3 * Math.sin(time * 0.02) * (heat / 100);
// ground / base plate
ctx.fillStyle = "#3a2018";
var baseY = Math.max(b.y + b.h, fly.cy + fly.r) + 6;
roundRect(L.ox - 6, baseY, L.ew + 12, 16, 8);
ctx.fill();
// ----- FIRE BOX -----
// fire glow on the ground
if (heat > 0) {
var glow = ctx.createRadialGradient(
fire.x + fire.w / 2, fire.y + fire.h, 4,
fire.x + fire.w / 2, fire.y + fire.h, fire.w * (0.8 + heat / 90));
glow.addColorStop(0, "rgba(255,150,40," + (0.5 * heat / 100) + ")");
glow.addColorStop(1, "rgba(255,150,40,0)");
ctx.fillStyle = glow;
ctx.fillRect(fire.x - fire.w, fire.y - fire.h, fire.w * 3, fire.h * 2.4);
}
// fire box frame
ctx.fillStyle = "#1c0e08";
roundRect(fire.x - 5, fire.y, fire.w + 10, fire.h + 4, 6);
ctx.fill();
// coals
ctx.fillStyle = heat > 0 ? "rgba(120,40,20,1)" : "rgba(60,40,36,1)";
for (var cI = 0; cI < 6; cI++) {
var coalX = fire.x + (cI + 0.5) / 6 * fire.w;
ctx.beginPath();
ctx.arc(coalX, fire.y + fire.h - 3, fire.w * 0.07, 0, Math.PI * 2);
ctx.fill();
}
if (heat > 0) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (var ci2 = 0; ci2 < 6; ci2++) {
var cx2 = fire.x + (ci2 + 0.5) / 6 * fire.w;
ctx.fillStyle = "rgba(255,120,30," + (0.5 * heat / 100 * flicker) + ")";
ctx.beginPath();
ctx.arc(cx2, fire.y + fire.h - 3, fire.w * 0.09, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// fire particles
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (var fI = 0; fI < fireParticles.length; fI++) {
var fp = fireParticles[fI];
var lf = Math.max(0, fp.life);
// color shifts yellow->orange->red as it cools
var r = 255;
var g = Math.round(60 + 170 * lf);
var bl = Math.round(30 * lf);
var fg = ctx.createRadialGradient(fp.x, fp.y, 0, fp.x, fp.y, fp.size * (0.5 + lf));
fg.addColorStop(0, "rgba(" + r + "," + g + "," + bl + "," + (0.55 * lf) + ")");
fg.addColorStop(1, "rgba(" + r + "," + g + "," + bl + ",0)");
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(fp.x, fp.y, fp.size * (0.6 + lf), 0, Math.PI * 2);
ctx.fill();
}
// embers
for (var eI = 0; eI < emberParticles.length; eI++) {
var ep = emberParticles[eI];
ctx.fillStyle = "rgba(255,210,90," + Math.max(0, ep.life) + ")";
ctx.beginPath();
ctx.arc(ep.x, ep.y, ep.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// ----- BOILER -----
// metal shell
var shell = ctx.createLinearGradient(b.x, b.y, b.x, b.y + b.h);
shell.addColorStop(0, "#8a4a3a");
shell.addColorStop(0.5, "#5e2e26");
shell.addColorStop(1, "#3a1c18");
ctx.fillStyle = shell;
roundRect(b.x, b.y, b.w, b.h, 18);
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "#2a1410";
ctx.stroke();
// boiler rivets
ctx.fillStyle = "rgba(255,200,160,0.35)";
for (var rv = 0; rv < 5; rv++) {
var ry = b.y + 12 + rv / 4 * (b.h - 24);
ctx.beginPath(); ctx.arc(b.x + 9, ry, 2.4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(b.x + b.w - 9, ry, 2.4, 0, Math.PI * 2); ctx.fill();
}
// window into the boiler (cutaway) showing water + steam
var winPad = 14;
var winX = b.x + winPad, winY = b.y + winPad;
var winW = b.w - winPad * 2, winH = b.h - winPad * 2;
ctx.save();
roundRect(winX, winY, winW, winH, 10);
ctx.clip();
// steam space (above water)
var steamBg = ctx.createLinearGradient(0, winY, 0, L.waterTop);
steamBg.addColorStop(0, "#3d2a2a");
steamBg.addColorStop(1, "#52383a");
ctx.fillStyle = steamBg;
ctx.fillRect(winX, winY, winW, L.waterTop - winY);
// water
var waterBottom = winY + winH;
var waterGrad = ctx.createLinearGradient(0, L.waterTop, 0, waterBottom);
waterGrad.addColorStop(0, "#3aa6c8");
waterGrad.addColorStop(1, "#1d5e80");
ctx.fillStyle = waterGrad;
// wavy water surface
ctx.beginPath();
ctx.moveTo(winX, L.waterTop);
var waveAmp = 2 + heat / 22;
for (var wx = 0; wx <= winW; wx += 6) {
var wy = L.waterTop + Math.sin((wx / winW) * 6.5 + time * 0.006 * (1 + heat / 60)) * waveAmp;
ctx.lineTo(winX + wx, wy);
}
ctx.lineTo(winX + winW, waterBottom);
ctx.lineTo(winX, waterBottom);
ctx.closePath();
ctx.fill();
// rising bubbles in water
if (heat > 6) {
ctx.fillStyle = "rgba(220,245,255,0.55)";
var bubbleCount = Math.round(heat / 12);
for (var bb = 0; bb < bubbleCount; bb++) {
var seed = bb * 97.13;
var bxF = (Math.sin(seed) * 0.5 + 0.5);
var bx = winX + 8 + bxF * (winW - 16);
var phase = (time * 0.001 * (0.6 + heat / 120) + bb * 0.37) % 1;
var by = waterBottom - phase * (waterBottom - L.waterTop);
var br = 1.4 + (1 - phase) * 2.4;
ctx.globalAlpha = 0.4 + 0.4 * phase;
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// steam clouds inside boiler
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (var sI = 0; sI < steamParticles.length; sI++) {
var sp = steamParticles[sI];
if (!sp.inBoiler) continue;
var sa = Math.max(0, sp.life) * 0.4;
var sg = ctx.createRadialGradient(sp.x, sp.y, 0, sp.x, sp.y, sp.size);
sg.addColorStop(0, "rgba(255,255,255," + sa + ")");
sg.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = sg;
ctx.beginPath();
ctx.arc(sp.x, sp.y, sp.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.restore(); // unclip window
// window frame
ctx.lineWidth = 4;
ctx.strokeStyle = "#2a1410";
roundRect(winX, winY, winW, winH, 10);
ctx.stroke();
// ----- STEAM WHISTLE on the boiler -----
drawWhistle();
// ----- STEAM PIPE from boiler top to cylinder -----
var pipeY = b.y + b.h * 0.12;
var pipeStartX = b.x + b.w - 4;
var pipeEndX = cyl.x + 6;
ctx.lineWidth = Math.max(8, L.eh * 0.06);
ctx.lineCap = "round";
ctx.strokeStyle = "#6b3a2e";
ctx.beginPath();
ctx.moveTo(b.x + b.w * 0.78, b.y + 4);
ctx.lineTo(b.x + b.w * 0.78, pipeY);
ctx.lineTo(pipeEndX, pipeY);
ctx.lineTo(pipeEndX, cyl.y + cyl.h * 0.3);
ctx.stroke();
ctx.lineWidth = Math.max(4, L.eh * 0.03);
ctx.strokeStyle = "#9a5a48";
ctx.stroke();
// moving steam glow inside the pipe when hot
if (heat > 8) {
var pulse = (Math.sin(time * 0.006) + 1) / 2;
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = "rgba(255,255,255," + (0.18 + 0.22 * pulse) * (heat / 100) + ")";
ctx.lineWidth = Math.max(2, L.eh * 0.018);
ctx.beginPath();
ctx.moveTo(b.x + b.w * 0.78, b.y + 4);
ctx.lineTo(b.x + b.w * 0.78, pipeY);
ctx.lineTo(pipeEndX, pipeY);
ctx.lineTo(pipeEndX, cyl.y + cyl.h * 0.3);
ctx.stroke();
ctx.restore();
}
// ----- CYLINDER + PISTON -----
var pistonX = pistonXForAngle(crankAngle);
// cylinder body
var cylGrad = ctx.createLinearGradient(0, cyl.y, 0, cyl.y + cyl.h);
cylGrad.addColorStop(0, "#7a6a72");
cylGrad.addColorStop(0.5, "#4a3a44");
cylGrad.addColorStop(1, "#2c2028");
ctx.fillStyle = cylGrad;
roundRect(cyl.x, cyl.y, cyl.w, cyl.h, 12);
ctx.fill();
// inside of cylinder (cutaway) — clip
ctx.save();
var inPad = 6;
roundRect(cyl.x + inPad, cyl.y + inPad, cyl.w - inPad * 2, cyl.h - inPad * 2, 8);
ctx.clip();
// dark bore
ctx.fillStyle = "#1a1218";
ctx.fillRect(cyl.x, cyl.y, cyl.w, cyl.h);
// steam pressure behind piston (left side) — brighter with heat
var steamFill = ctx.createLinearGradient(cyl.x, 0, pistonX, 0);
var sIntensity = (heat / 100) * (0.35 + 0.4 * ((Math.cos(crankAngle) + 1) / 2));
steamFill.addColorStop(0, "rgba(255,255,255," + (0.15 + sIntensity) + ")");
steamFill.addColorStop(1, "rgba(255,255,255,0.04)");
ctx.fillStyle = steamFill;
ctx.fillRect(cyl.x + inPad, cyl.y + inPad, pistonX - L.pistonW / 2 - (cyl.x + inPad), cyl.h - inPad * 2);
// little steam swirls behind piston
if (heat > 8) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (var sw = 0; sw < 5; sw++) {
var swPhase = (time * 0.002 + sw * 0.4) % 1;
var swX = cyl.x + inPad + swPhase * (pistonX - L.pistonW / 2 - cyl.x - inPad);
var swY = L.pistonY + Math.sin(time * 0.004 + sw) * cyl.h * 0.22;
ctx.fillStyle = "rgba(255,255,255," + (0.12 * heat / 100) + ")";
ctx.beginPath();
ctx.arc(swX, swY, cyl.h * 0.14, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// piston head
var pistonGrad = ctx.createLinearGradient(0, cyl.y, 0, cyl.y + cyl.h);
pistonGrad.addColorStop(0, "#ffd24d");
pistonGrad.addColorStop(0.5, "#e8902c");
pistonGrad.addColorStop(1, "#b3601c");
ctx.fillStyle = pistonGrad;
roundRect(pistonX - L.pistonW / 2, cyl.y + inPad + 1, L.pistonW, cyl.h - inPad * 2 - 2, 4);
ctx.fill();
ctx.fillStyle = "rgba(0,0,0,0.25)";
ctx.fillRect(pistonX - L.pistonW * 0.18, cyl.y + inPad + 1, L.pistonW * 0.12, cyl.h - inPad * 2 - 2);
ctx.restore(); // unclip cylinder
// cylinder frame
ctx.lineWidth = 3;
ctx.strokeStyle = "#1c141a";
roundRect(cyl.x, cyl.y, cyl.w, cyl.h, 12);
ctx.stroke();
// ----- CONNECTING ROD + CRANK -----
var pinX = fly.cx + L.crankR * Math.cos(crankAngle);
var pinY = fly.cy + L.crankR * Math.sin(crankAngle);
// piston rod end (right side of piston head)
var rodStartX = pistonX + L.pistonW / 2;
var rodStartY = L.pistonY;
// piston rod (the straight part poking out of cylinder)
ctx.lineWidth = Math.max(5, L.eh * 0.035);
ctx.lineCap = "round";
ctx.strokeStyle = "#c9c0c4";
ctx.beginPath();
ctx.moveTo(rodStartX, rodStartY);
ctx.lineTo(Math.min(pinX, cyl.x + cyl.w + L.crankR * 0.3), rodStartY);
ctx.stroke();
// connecting rod to crank pin
ctx.lineWidth = Math.max(6, L.eh * 0.045);
var rodGrad = ctx.createLinearGradient(rodStartX, rodStartY, pinX, pinY);
rodGrad.addColorStop(0, "#d8cfd3");
rodGrad.addColorStop(1, "#8a8086");
ctx.strokeStyle = rodGrad;
ctx.beginPath();
ctx.moveTo(rodStartX, rodStartY);
ctx.lineTo(pinX, pinY);
ctx.stroke();
// rod end bearings
ctx.fillStyle = "#3a3036";
ctx.beginPath(); ctx.arc(rodStartX, rodStartY, L.eh * 0.028, 0, Math.PI * 2); ctx.fill();
// ----- FLYWHEEL -----
// shadow
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath();
ctx.arc(fly.cx + 4, fly.cy + 6, fly.r, 0, Math.PI * 2);
ctx.fill();
// outer rim
var rimGrad = ctx.createRadialGradient(
fly.cx - fly.r * 0.3, fly.cy - fly.r * 0.3, fly.r * 0.2,
fly.cx, fly.cy, fly.r);
rimGrad.addColorStop(0, "#ff8a5c");
rimGrad.addColorStop(0.7, "#d4452a");
rimGrad.addColorStop(1, "#7c2418");
ctx.fillStyle = rimGrad;
ctx.beginPath();
ctx.arc(fly.cx, fly.cy, fly.r, 0, Math.PI * 2);
ctx.fill();
// inner cut
ctx.fillStyle = "#241010";
ctx.beginPath();
ctx.arc(fly.cx, fly.cy, fly.r * 0.74, 0, Math.PI * 2);
ctx.fill();
// spokes (rotate with crank)
ctx.save();
ctx.translate(fly.cx, fly.cy);
ctx.rotate(crankAngle);
var spokes = 6;
for (var sp2 = 0; sp2 < spokes; sp2++) {
ctx.save();
ctx.rotate((Math.PI * 2 / spokes) * sp2);
var spkGrad = ctx.createLinearGradient(0, -fly.r * 0.1, 0, -fly.r * 0.78);
spkGrad.addColorStop(0, "#9a3422");
spkGrad.addColorStop(1, "#e06038");
ctx.fillStyle = spkGrad;
ctx.beginPath();
ctx.moveTo(-fly.r * 0.06, -fly.r * 0.16);
ctx.lineTo(fly.r * 0.06, -fly.r * 0.16);
ctx.lineTo(fly.r * 0.04, -fly.r * 0.78);
ctx.lineTo(-fly.r * 0.04, -fly.r * 0.78);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// hub
ctx.fillStyle = "#ffb33c";
ctx.beginPath();
ctx.arc(0, 0, fly.r * 0.16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#5e2e1a";
ctx.beginPath();
ctx.arc(0, 0, fly.r * 0.07, 0, Math.PI * 2);
ctx.fill();
// bright marker spoke so spin is obvious
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(0, -fly.r * 0.84, Math.max(4, fly.r * 0.08), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffd24d";
ctx.beginPath();
ctx.arc(0, -fly.r * 0.84, Math.max(2.4, fly.r * 0.05), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// rim highlight ring
ctx.lineWidth = Math.max(2, fly.r * 0.04);
ctx.strokeStyle = "rgba(255,210,170,0.4)";
ctx.beginPath();
ctx.arc(fly.cx, fly.cy, fly.r * 0.88, 0, Math.PI * 2);
ctx.stroke();
// crank pin on top of everything
ctx.fillStyle = "#3a3036";
ctx.beginPath();
ctx.arc(pinX, pinY, L.eh * 0.03, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#c9c0c4";
ctx.beginPath();
ctx.arc(pinX, pinY, L.eh * 0.016, 0, Math.PI * 2);
ctx.fill();
// ----- exhaust steam puffs (outside boiler) -----
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (var sJ = 0; sJ < steamParticles.length; sJ++) {
var sp2p = steamParticles[sJ];
if (sp2p.inBoiler) continue;
var sa2 = Math.max(0, sp2p.life) * 0.5;
var sg2 = ctx.createRadialGradient(sp2p.x, sp2p.y, 0, sp2p.x, sp2p.y, sp2p.size);
sg2.addColorStop(0, "rgba(255,255,255," + sa2 + ")");
sg2.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = sg2;
ctx.beginPath();
ctx.arc(sp2p.x, sp2p.y, sp2p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
// ----- GOVERNOR (spinning balls) -----
drawGovernor();
// ----- PRESSURE GAUGE -----
drawGauge();
// ----- LABELS -----
label("Fire", fire.x + fire.w / 2, fire.y + fire.h + 16);
label("Steam", b.x + b.w * 0.5, L.waterTop - b.h * 0.22);
label("Water", b.x + b.w * 0.5, L.waterTop + b.h * 0.2);
label("Rod", (rodStartX + pinX) / 2, (rodStartY + pinY) / 2 - 14);
label("Flywheel", fly.cx, fly.cy + fly.r + 16);
// piston stroke label: tells the kid which way the piston is travelling.
// piston X velocity sign decides PUSH (steam-driven, moving right) vs
// RETURN (flywheel carries it back left).
var aheadX = pistonXForAngle(crankAngle + Math.sign(crankSpeed || 1) * 0.12);
var moving = aheadX - pistonX;
var strokeTxt;
if (Math.abs(crankSpeed) < 0.25) strokeTxt = "Piston";
else if (moving > 0) strokeTxt = "PUSH →";
else strokeTxt = "← RETURN";
label(strokeTxt, pistonX, cyl.y - 12);
}
// ---- pressure gauge ----
// A round dial whose needle climbs with boiler pressure. Green when low,
// amber in the middle, red in the danger zone — easy for a kid to read.
function drawGauge() {
var ga = L.gauge;
if (!ga) return;
var r = ga.r;
// mounting pipe down to the boiler
ctx.strokeStyle = "#6b3a2e";
ctx.lineWidth = Math.max(4, r * 0.22);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(ga.cx, ga.cy + r * 0.55);
ctx.lineTo(ga.cx, ga.cy + r + 10);
ctx.stroke();
// dial body
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath();
ctx.arc(ga.cx + 2, ga.cy + 3, r, 0, Math.PI * 2);
ctx.fill();
var faceGrad = ctx.createRadialGradient(
ga.cx - r * 0.3, ga.cy - r * 0.3, r * 0.1, ga.cx, ga.cy, r);
faceGrad.addColorStop(0, "#fff6e6");
faceGrad.addColorStop(1, "#d8c3a0");
ctx.fillStyle = faceGrad;
ctx.beginPath();
ctx.arc(ga.cx, ga.cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = Math.max(2, r * 0.1);
ctx.strokeStyle = "#5e3a22";
ctx.stroke();
// coloured arc: needle sweeps a 240-degree dial
var a0 = Math.PI * 0.75; // start angle (lower-left)
var a1 = Math.PI * 2.25; // end angle (lower-right)
var span = a1 - a0;
function arcSeg(f0, f1, color) {
ctx.beginPath();
ctx.arc(ga.cx, ga.cy, r * 0.74, a0 + span * f0, a0 + span * f1);
ctx.lineWidth = Math.max(3, r * 0.16);
ctx.strokeStyle = color;
ctx.stroke();
}
arcSeg(0, 0.55, "#3fae54");
arcSeg(0.55, 0.8, "#e8a72c");
arcSeg(0.8, 1, "#e0432a");
// tick marks
ctx.strokeStyle = "#5e3a22";
ctx.lineWidth = Math.max(1.5, r * 0.05);
for (var t = 0; t <= 8; t++) {
var ta = a0 + span * (t / 8);
ctx.beginPath();
ctx.moveTo(ga.cx + Math.cos(ta) * r * 0.56, ga.cy + Math.sin(ta) * r * 0.56);
ctx.lineTo(ga.cx + Math.cos(ta) * r * 0.66, ga.cy + Math.sin(ta) * r * 0.66);
ctx.stroke();
}
// needle
var pf = Math.max(0, Math.min(1, pressure / 100));
var na = a0 + span * pf;
ctx.save();
ctx.translate(ga.cx, ga.cy);
ctx.rotate(na);
ctx.fillStyle = "#c2261a";
ctx.beginPath();
ctx.moveTo(-r * 0.12, -r * 0.05);
ctx.lineTo(r * 0.72, 0);
ctx.lineTo(-r * 0.12, r * 0.05);
ctx.closePath();
ctx.fill();
ctx.restore();
// hub
ctx.fillStyle = "#3a2418";
ctx.beginPath();
ctx.arc(ga.cx, ga.cy, r * 0.13, 0, Math.PI * 2);
ctx.fill();
// "PSI" caption inside the dial
ctx.fillStyle = "#5e3a22";
ctx.font = "700 " + Math.max(8, r * 0.26) + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("PSI", ga.cx, ga.cy + r * 0.42);
label("Pressure", ga.cx, ga.cy - r - 11);
}
// ---- governor ----
// Two heavy balls spin on hinged arms. The faster the engine runs, the more
// they fly outward — a real steam-engine speed sensor.
function drawGovernor() {
var gv = L.gov;
if (!gv) return;
// how far out the balls swing: 0 (hanging down) .. 1 (flung wide)
var speedFrac = Math.max(0, Math.min(1, Math.abs(crankSpeed) / 7.2));
var swing = 0.12 + speedFrac * 0.78; // fraction of full outward swing
var armLen = gv.r;
var ballR = gv.ballR;
// central spindle
var spindleTop = gv.cy - armLen * 0.55;
var spindleBot = gv.cy + armLen * 1.05;
ctx.strokeStyle = "#7a5a3a";
ctx.lineWidth = Math.max(3, armLen * 0.16);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(gv.cx, spindleTop);
ctx.lineTo(gv.cx, spindleBot);
ctx.stroke();
// collar that slides up the spindle as the balls rise
var collarY = spindleBot - swing * armLen * 0.7;
ctx.fillStyle = "#b89a6a";
roundRect(gv.cx - armLen * 0.2, collarY - armLen * 0.08,
armLen * 0.4, armLen * 0.16, armLen * 0.06);
ctx.fill();
// the two arms + balls. The 3-D spin is faked: each ball's apparent
// horizontal reach is scaled by cos(govAngle) so it looks like it orbits.
var pivotY = spindleTop + armLen * 0.18;
// swing angle from vertical
var swingAng = swing * (Math.PI * 0.46);
var spin = Math.cos(govAngle); // -1..1 perspective factor
for (var s = 0; s < 2; s++) {
var side = s === 0 ? 1 : -1;
var reach = Math.sin(swingAng) * armLen * side * spin;
var drop = Math.cos(swingAng) * armLen;
var bx = gv.cx + reach;
var by = pivotY + drop;
// arm from pivot to ball
ctx.strokeStyle = "#9a7a4a";
ctx.lineWidth = Math.max(2.5, armLen * 0.1);
ctx.beginPath();
ctx.moveTo(gv.cx, pivotY);
ctx.lineTo(bx, by);
ctx.stroke();
// link from ball back down to the sliding collar
ctx.strokeStyle = "#7a5a3a";
ctx.lineWidth = Math.max(1.8, armLen * 0.06);
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(gv.cx, collarY);
ctx.stroke();
// the heavy ball — slightly bigger when it's the near one
var nearScale = 0.86 + 0.14 * ((spin * side) + 1) / 2;
var br = ballR * nearScale;
var bGrad = ctx.createRadialGradient(
bx - br * 0.35, by - br * 0.35, 1, bx, by, br);
bGrad.addColorStop(0, "#ffd24d");
bGrad.addColorStop(0.55, "#e8902c");
bGrad.addColorStop(1, "#8a4a18");
ctx.fillStyle = bGrad;
ctx.beginPath();
ctx.arc(bx, by, br, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#5e2e10";
ctx.stroke();
}
// pivot cap on top
ctx.fillStyle = "#3a2418";
ctx.beginPath();
ctx.arc(gv.cx, pivotY, Math.max(3, armLen * 0.1), 0, Math.PI * 2);
ctx.fill();
label("Governor", gv.cx, spindleTop - 11);
}
// ---- steam whistle ----
// A small brass whistle on the boiler. It glows and shudders while blowing.
function drawWhistle() {
var wh = L.whistle;
if (!wh) return;
var h = wh.h;
var wWidth = h * 0.42;
var shake = whistlePuff > 0 ? Math.sin(performance.now() * 0.08) * 1.6 : 0;
var x = wh.x + shake;
// base flange on the boiler
ctx.fillStyle = "#9a5a48";
roundRect(x - wWidth * 0.8, wh.y - 3, wWidth * 1.6, 6, 3);
ctx.fill();
// brass body
var brass = ctx.createLinearGradient(x - wWidth / 2, 0, x + wWidth / 2, 0);
brass.addColorStop(0, "#8a5a1c");
brass.addColorStop(0.5, "#ffd24d");
brass.addColorStop(1, "#8a5a1c");
ctx.fillStyle = brass;
roundRect(x - wWidth / 2, wh.y - h, wWidth, h, wWidth * 0.3);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = "#5e3a10";
ctx.stroke();
// bell cap
ctx.fillStyle = "#ffe6b8";
roundRect(x - wWidth * 0.62, wh.y - h - 4, wWidth * 1.24, 6, 3);
ctx.fill();
// hot glow while whistling
if (whistlePuff > 0) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
var gl = ctx.createRadialGradient(x, wh.y - h, 1, x, wh.y - h, h);
gl.addColorStop(0, "rgba(255,240,200," + (0.5 * whistlePuff) + ")");
gl.addColorStop(1, "rgba(255,240,200,0)");
ctx.fillStyle = gl;
ctx.beginPath();
ctx.arc(x, wh.y - h, h, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
// ---- main loop ----
var lastT = 0;
function frame(t) {
var dt = lastT ? Math.min(0.05, (t - lastT) / 1000) : 0.016;
lastT = t;
// boiler pressure eases toward the heat level — it builds and bleeds slowly
pressure += (heat - pressure) * Math.min(1, dt * 0.9);
if (pressure < 0) pressure = 0;
if (pressure > 100) pressure = 100;
// heat sets a target flywheel speed; speed eases toward it (inertia)
var targetSpeed = (heat / 100) * 7.2; // rad/sec at full heat
crankSpeed += (targetSpeed - crankSpeed) * Math.min(1, dt * 1.4);
if (heat <= 0 && Math.abs(crankSpeed) < 0.03) crankSpeed = 0;
crankAngle += crankSpeed * dt;
if (crankAngle > Math.PI * 2) crankAngle -= Math.PI * 2;
// the governor is belt-driven by the flywheel — it spins a bit faster
govAngle += crankSpeed * 1.5 * dt;
if (govAngle > Math.PI * 2) govAngle -= Math.PI * 2;
// the whistle's steam burst fades quickly after a tap
if (whistlePuff > 0) {
whistlePuff -= dt * 1.6;
if (whistlePuff < 0) whistlePuff = 0;
}
spawnFire();
spawnSteam();
updateParticles(dt);
draw(t);
requestAnimationFrame(frame);
}
// ---- input ----
function onHeat() {
heat = parseInt(heatSlider.value, 10) || 0;
heatVal.textContent = heat;
}
heatSlider.addEventListener("input", onHeat);
heatSlider.addEventListener("change", onHeat);
// ---- steam whistle (Web Audio) ----
// The AudioContext is created lazily on the FIRST user tap, so it always
// starts from a real gesture and never throws an autoplay warning.
var audioCtx = null;
var whistleBusy = false;
function blowWhistle() {
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
if (!audioCtx) audioCtx = new AC();
if (audioCtx.state === "suspended" && audioCtx.resume) {
audioCtx.resume();
}
if (whistleBusy) return; // don't stack overlapping whistles
whistleBusy = true;
var now = audioCtx.currentTime;
var dur = 0.85;
// louder, brighter whistle when the boiler has more pressure
var pf = Math.max(0, Math.min(1, pressure / 100));
var vol = 0.12 + pf * 0.18;
var baseFreq = 620 + pf * 260;
var master = audioCtx.createGain();
master.gain.setValueAtTime(0.0001, now);
master.gain.exponentialRampToValueAtTime(vol, now + 0.06);
master.gain.setValueAtTime(vol, now + dur - 0.25);
master.gain.exponentialRampToValueAtTime(0.0001, now + dur);
master.connect(audioCtx.destination);
// two slightly detuned tones give that breathy steam-whistle chord
var offsets = [1, 1.5];
for (var i = 0; i < offsets.length; i++) {
var osc = audioCtx.createOscillator();
osc.type = "sine";
var f = baseFreq * offsets[i];
osc.frequency.setValueAtTime(f * 0.92, now);
osc.frequency.linearRampToValueAtTime(f, now + 0.12);
osc.frequency.setValueAtTime(f, now + dur - 0.2);
osc.frequency.linearRampToValueAtTime(f * 0.9, now + dur);
var g = audioCtx.createGain();
g.gain.value = i === 0 ? 1 : 0.5;
osc.connect(g);
g.connect(master);
osc.start(now);
osc.stop(now + dur + 0.05);
}
// a hiss of breathy noise on top
var noiseLen = Math.floor(audioCtx.sampleRate * dur);
var buf = audioCtx.createBuffer(1, noiseLen, audioCtx.sampleRate);
var data = buf.getChannelData(0);
for (var n = 0; n < noiseLen; n++) data[n] = (Math.random() * 2 - 1);
var noise = audioCtx.createBufferSource();
noise.buffer = buf;
var nFilter = audioCtx.createBiquadFilter();
nFilter.type = "bandpass";
nFilter.frequency.value = baseFreq * 2;
nFilter.Q.value = 1.2;
var nGain = audioCtx.createGain();
nGain.gain.value = 0.18;
noise.connect(nFilter);
nFilter.connect(nGain);
nGain.connect(master);
noise.start(now);
noise.stop(now + dur + 0.05);
whistlePuff = 1; // trigger the visual steam burst
setTimeout(function () { whistleBusy = false; }, (dur + 0.1) * 1000);
} catch (err) {
whistleBusy = false;
}
}
// visual: a quick extra burst of steam from the whistle when it blows
var whistlePuff = 0;
// tapping the engine (the canvas) blows the whistle
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
blowWhistle();
});
var howBtn = document.getElementById("how");
var howPanel = document.getElementById("howPanel");
howBtn.addEventListener("click", function () {
howPanel.classList.toggle("open");
});
document.addEventListener("pointerdown", function (e) {
if (howPanel.classList.contains("open") &&
!howPanel.contains(e.target) && e.target !== howBtn) {
howPanel.classList.remove("open");
}
});
window.addEventListener("resize", resize);
// ---- boot ----
resize();
onHeat();
requestAnimationFrame(frame);
requestAnimationFrame(function () { stage.classList.add("ready"); });
})();
</script>
</body>
</html>
Game Source: Steam Engine
Creator: ArcadeLab
Libraries: none
Complexity: complex (1311 lines, 42.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: steam-engine-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.