Gear Train
by ArcadeLab868 lines27.5 KB
<!--ARCADELAB
title: Gear Train
description: A hands-on machine of meshing gears โ turn the hand crank or drag any gear, change the small gear's size, and watch live RPM numbers as small gears whirl fast while big gears turn slow.
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>Gear Train</title>
<style>
:root {
--bg: #1a1206;
--panel: rgba(48, 32, 12, 0.86);
--accent: #ffb24d;
--accent2: #ff7a3c;
--text: #fff3e0;
--muted: #e6c89a;
}
* { 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, 178, 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(13px, 3.6vw, 15px);
font-weight: 600;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(255, 178, 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(74, 50, 18, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
button:disabled {
opacity: 0.4;
cursor: default;
transform: none;
}
button:disabled:hover { background: var(--panel); border-color: rgba(255, 178, 77, 0.36); }
#info {
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;
}
#ratio {
background: var(--panel);
border: 1px solid rgba(255, 178, 77, 0.3);
border-radius: 13px;
padding: 9px 16px;
font-size: clamp(13px, 3.7vw, 16px);
font-weight: 600;
color: var(--text);
text-align: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 92vw;
}
#ratio b { color: var(--accent); font-variant-numeric: tabular-nums; }
#hint {
background: var(--panel);
border: 1px solid rgba(255, 178, 77, 0.2);
border-radius: 13px;
padding: 7px 13px;
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(96px, 20vw, 120px);
transform: translateX(-50%) translateY(12px);
width: min(420px, 90vw);
background: var(--panel);
border: 1px solid rgba(255, 178, 77, 0.4);
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; color: var(--text); }
#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>Gear Train</span></div>
<div class="spacer"></div>
<div class="controls">
<button id="smaller" type="button" aria-label="Make the small gear smaller">โ teeth</button>
<button id="bigger" type="button" aria-label="Make the small gear bigger">โ teeth</button>
<button id="how" type="button">How it works</button>
<button id="reset" type="button">Reset</button>
</div>
</div>
<div id="howPanel">
<h2>How it works</h2>
<p>Gears with teeth lock together and push each other around.</p>
<p>Touching gears always spin in opposite directions.</p>
<p>A small gear has fewer teeth, so it must spin many more times to keep up with a big gear. That is a gear ratio!</p>
<p>Turn the red hand crank, or drag any gear, to spin the whole train.</p>
<p>Use โ and โ to change the small gear's teeth โ watch the RPM numbers and the ratio change!</p>
</div>
<div id="info">
<div id="ratio">Drag a gear to spin the train</div>
<div id="hint">Big gear turns slow ยท small gear turns fast</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 ratioEl = document.getElementById("ratio");
var W = 0, H = 0, DPR = 1;
var gears = [];
// hand crank attached to the big gear (gears[0]). It rotates with that gear.
var crank = { armLen: 0, gripR: 0 };
// momentum of the train (radians/sec of the reference driver gear)
var driverSpeed = 0; // current angular speed applied to gears[0]
var TOOTH_MODULE = 0; // pixels per tooth-pitch unit, computed on layout
function rand(a, b) { return a + Math.random() * (b - a); }
// ---- gear model ----
// Each gear: center (cx,cy), tooth count, angle, color.
// Pitch radius = teeth * module / 2. Meshing gears share the same module.
function makeGear(teeth, color, label) {
return {
teeth: teeth,
cx: 0, cy: 0,
pitchR: 0,
angle: 0,
dir: 1, // spin direction relative to driver (+1 / -1)
speedMul: 1, // angular-speed multiplier vs driver
rpm: 0, // live revolutions-per-minute (for display)
color: color,
label: label
};
}
// teeth count of the adjustable small gear
var SMALL_MIN = 6, SMALL_MAX = 16, SMALL_DEFAULT = 10;
var smallTeeth = SMALL_DEFAULT;
function buildTrain() {
gears = [
makeGear(30, "#ff8c3c", "BIG"),
makeGear(18, "#ffd24d", "MID"),
makeGear(smallTeeth, "#7fd0ff", "SMALL")
];
}
// Lay the train out across the available space, sized to fit.
function layout() {
var big = gears[0], mid = gears[1], small = gears[2];
var TIP = 1.16; // outer (tooth-tip) radius / pitch radius
// module chosen so the whole chain โ including tooth tips โ fits on screen
var avail = Math.min(W * 0.94, W - 24);
// full visual span = bigOuter + d1 + d2 + smallOuter, in module units / 2
var spanCoef = (TIP * big.teeth + (big.teeth + mid.teeth) +
(mid.teeth + small.teeth) + TIP * small.teeth) / 2;
var maxModule = avail / spanCoef;
// leave vertical room below the gears for the two label rows (teeth + RPM)
var heightModule = (H * 0.50) / (big.teeth * TIP + 4);
TOOTH_MODULE = Math.max(2.2, Math.min(maxModule, heightModule));
for (var i = 0; i < gears.length; i++) {
gears[i].pitchR = gears[i].teeth * TOOTH_MODULE / 2;
}
var cy = H * 0.46;
// Distance between meshing gear centers = sum of their pitch radii.
var d1 = big.pitchR + mid.pitchR;
var d2 = mid.pitchR + small.pitchR;
var bigOuter = big.pitchR * TIP;
var smallOuter = small.pitchR * TIP;
var fullSpan = bigOuter + d1 + d2 + smallOuter;
var startX = (W - fullSpan) / 2 + bigOuter;
big.cx = startX;
big.cy = cy;
mid.cx = startX + d1;
mid.cy = cy;
small.cx = startX + d1 + d2;
small.cy = cy;
// keep gears on screen vertically if very short window
var maxR = bigOuter;
if (cy - maxR < 70) {
var shift = 70 - (cy - maxR);
big.cy += shift; mid.cy += shift; small.cy += shift;
}
// direction + speed multipliers (driver = big gear, index 0)
// meshed gear spins opposite, speed multiplied by teethDriver/teethDriven
big.dir = 1; big.speedMul = 1;
mid.dir = -1; mid.speedMul = big.teeth / mid.teeth;
small.dir = 1; small.speedMul = big.teeth / small.teeth;
// hand crank: a handle that orbits the big gear's hub
crank.armLen = big.pitchR * 0.62; // distance from hub to grip
crank.gripR = Math.max(14, big.pitchR * 0.16); // grip knob radius (tap target)
}
// ---- physics / spin ----
// The whole train is rigid. We track one driver angle; others follow.
// Meshing constraint: surface speeds match at the pitch circle, so
// gearB.angularSpeed = gearA.angularSpeed * (teethA / teethB), opposite sign.
function applyTrain(driverAngle) {
gears[0].angle = driverAngle;
for (var i = 1; i < gears.length; i++) {
var g = gears[i];
// angle relative to driver. We add a phase offset so teeth visually
// interlock; the offset is baked once the train is laid out.
g.angle = driverAngle * (-g.dir * g.speedMul) + g.phase;
}
}
// Compute phase offsets so teeth mesh cleanly at the contact point.
function computePhases() {
gears[0].phase = 0;
gears[0].angle = 0;
for (var i = 1; i < gears.length; i++) {
var prev = gears[i - 1];
var g = gears[i];
// direction from this gear's center toward the previous (meshing) gear
var contactAng = Math.atan2(prev.cy - g.cy, prev.cx - g.cx);
var prevContactAng = Math.atan2(g.cy - prev.cy, g.cx - prev.cx);
// a tooth tip of prev at prevContactAng must align with a valley of g.
// Tooth angular pitch:
var pitchPrev = (Math.PI * 2) / prev.teeth;
var pitchG = (Math.PI * 2) / g.teeth;
// local tooth coordinate of prev at the contact direction
var prevLocal = prevContactAng - prev.angle;
// we want g to present a valley there. Solve g.angle.
// g.angle = driverAngle*(-dir*mul) + phase ; at driverAngle=0:
g.phase = 0;
g.angle = 0;
// current g local angle at contact
var gLocal = contactAng - g.angle;
// tooth fraction of prev at contact (0 = tooth center)
var prevFrac = prevLocal / pitchPrev;
// we want g's tooth fraction at contact to be offset by 0.5 (valley)
var wantGFrac = prevFrac + 0.5;
var curGFrac = gLocal / pitchG;
var deltaFrac = wantGFrac - curGFrac;
// rotating g by +deltaAngle changes gLocal by -deltaAngle => frac by -deltaAngle/pitch
// so deltaAngle = -deltaFrac * pitchG
g.phase = -deltaFrac * pitchG;
g.angle = g.phase;
}
}
// ---- input ----
var dragging = null; // gear being dragged
var dragPointerId = null;
var lastPointerAng = 0; // angle of pointer around dragged gear's center
var lastMoveTime = 0;
var pointerVel = 0; // measured angular velocity of the dragged gear
function pointFromEvent(e) {
var rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function gearAt(x, y) {
for (var i = 0; i < gears.length; i++) {
var g = gears[i];
var dx = x - g.cx, dy = y - g.cy;
var outer = g.pitchR + g.pitchR * 0.16;
if (dx * dx + dy * dy <= outer * outer) return g;
}
return null;
}
// current position of the hand-crank grip knob (orbits the big gear's hub)
function crankGripPos() {
var big = gears[0];
return {
x: big.cx + Math.cos(big.angle) * crank.armLen,
y: big.cy + Math.sin(big.angle) * crank.armLen
};
}
function onCrankGrip(x, y) {
var gp = crankGripPos();
var dx = x - gp.x, dy = y - gp.y;
var hit = crank.gripR + 14; // generous tap target
return dx * dx + dy * dy <= hit * hit;
}
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
if (dragging) return;
var pt = pointFromEvent(e);
// the crank grip takes priority โ it's the easiest thing to grab
var g = onCrankGrip(pt.x, pt.y) ? gears[0] : gearAt(pt.x, pt.y);
if (!g) return;
dragging = g;
dragPointerId = e.pointerId;
lastPointerAng = Math.atan2(pt.y - g.cy, pt.x - g.cx);
lastMoveTime = performance.now();
pointerVel = 0;
driverSpeed = 0;
if (canvas.setPointerCapture) {
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
}
});
canvas.addEventListener("pointermove", function (e) {
if (!dragging || e.pointerId !== dragPointerId) return;
e.preventDefault();
var pt = pointFromEvent(e);
var g = dragging;
var ang = Math.atan2(pt.y - g.cy, pt.x - g.cx);
var delta = ang - lastPointerAng;
// normalize to [-pi, pi]
while (delta > Math.PI) delta -= Math.PI * 2;
while (delta < -Math.PI) delta += Math.PI * 2;
lastPointerAng = ang;
var now = performance.now();
var dt = Math.max(0.001, (now - lastMoveTime) / 1000);
lastMoveTime = now;
// The dragged gear rotates by `delta`. Convert that into driver rotation.
// dragged gear angle = driverAngle * (-dir*mul) + phase (for i>0)
// for driver (i==0): angle = driverAngle.
var driverDelta;
if (g === gears[0]) {
driverDelta = delta;
} else {
driverDelta = delta / (-g.dir * g.speedMul);
}
driverAngle += driverDelta;
applyTrain(driverAngle);
// measured angular velocity of the driver (for momentum after release)
var instVel = driverDelta / dt;
pointerVel = pointerVel * 0.6 + instVel * 0.4;
});
function endDrag(e) {
if (!dragging || e.pointerId !== dragPointerId) return;
e.preventDefault();
// give the train momentum based on the last drag speed
driverSpeed = Math.max(-14, Math.min(14, pointerVel));
dragging = null;
dragPointerId = null;
}
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", function (e) {
if (dragging && e.pointerId === dragPointerId) {
driverSpeed = Math.max(-14, Math.min(14, pointerVel));
dragging = null;
dragPointerId = null;
}
});
// ---- driver angle (master state) ----
var driverAngle = 0;
// ---- layout / 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();
computePhases();
applyTrain(driverAngle);
}
// ---- gear drawing ----
function drawGear(g) {
var teeth = g.teeth;
var pr = g.pitchR;
var addendum = pr * (0.16 + 1.2 / teeth); // tooth height outside pitch
var dedendum = pr * (0.16 + 1.0 / teeth); // valley depth inside pitch
var outerR = pr + addendum;
var rootR = pr - dedendum;
var holeR = Math.max(pr * 0.16, 8);
ctx.save();
ctx.translate(g.cx, g.cy);
// soft shadow
ctx.save();
ctx.translate(3, 5);
ctx.fillStyle = "rgba(0,0,0,0.35)";
drawGearPath(teeth, outerR, rootR, g.angle);
ctx.fill();
ctx.restore();
// gear body gradient
var grd = ctx.createRadialGradient(-outerR * 0.3, -outerR * 0.3, outerR * 0.1, 0, 0, outerR);
grd.addColorStop(0, lighten(g.color, 0.35));
grd.addColorStop(0.55, g.color);
grd.addColorStop(1, darken(g.color, 0.32));
ctx.fillStyle = grd;
drawGearPath(teeth, outerR, rootR, g.angle);
ctx.fill();
// tooth edge highlight
ctx.lineWidth = Math.max(1, pr * 0.025);
ctx.strokeStyle = darken(g.color, 0.45);
drawGearPath(teeth, outerR, rootR, g.angle);
ctx.stroke();
// inner rim ring
ctx.beginPath();
ctx.arc(0, 0, rootR * 0.78, 0, Math.PI * 2);
ctx.lineWidth = Math.max(1.5, pr * 0.05);
ctx.strokeStyle = darken(g.color, 0.25);
ctx.stroke();
// spoke marks so spin is visible
var spokeCount = 3;
ctx.save();
ctx.rotate(g.angle);
for (var s = 0; s < spokeCount; s++) {
ctx.save();
ctx.rotate((Math.PI * 2 / spokeCount) * s);
ctx.fillStyle = darken(g.color, 0.22);
ctx.beginPath();
ctx.moveTo(-pr * 0.07, holeR + 2);
ctx.lineTo(pr * 0.07, holeR + 2);
ctx.lineTo(pr * 0.045, rootR * 0.78 - 2);
ctx.lineTo(-pr * 0.045, rootR * 0.78 - 2);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.restore();
// center hub
var hub = ctx.createRadialGradient(-holeR * 0.4, -holeR * 0.4, 0, 0, 0, holeR + 4);
hub.addColorStop(0, lighten(g.color, 0.3));
hub.addColorStop(1, darken(g.color, 0.4));
ctx.fillStyle = hub;
ctx.beginPath();
ctx.arc(0, 0, holeR + 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#1a1206";
ctx.beginPath();
ctx.arc(0, 0, holeR, 0, Math.PI * 2);
ctx.fill();
// bright colored marker dot near the rim โ makes speed obvious
ctx.save();
ctx.rotate(g.angle);
var dotR = rootR * 0.55;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(dotR, 0, Math.max(4, pr * 0.13), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = markerColor(g);
ctx.beginPath();
ctx.arc(dotR, 0, Math.max(2.6, pr * 0.085), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.restore();
// tooth-count label under the gear
var labelY = g.cy + outerR + 8;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillStyle = "#fff3e0";
ctx.font = "700 " + Math.max(12, Math.min(20, pr * 0.32)) + "px system-ui, sans-serif";
ctx.fillText(g.teeth + " teeth", g.cx, labelY);
// live RPM readout in a little pill below the tooth count
var rpmTxt = Math.round(Math.abs(g.rpm)) + " RPM";
var rpmFont = Math.max(12, Math.min(19, pr * 0.30));
ctx.font = "700 " + rpmFont + "px system-ui, sans-serif";
var rpmW = ctx.measureText(rpmTxt).width;
var pillY = labelY + rpmFont + 9;
var padX = 9, padH = rpmFont + 9;
ctx.fillStyle = "rgba(26,18,6,0.9)";
roundRectGT(g.cx - rpmW / 2 - padX, pillY - padH / 2, rpmW + padX * 2, padH, padH / 2);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = markerColor(g);
ctx.stroke();
ctx.fillStyle = "#ffe6b8";
ctx.textBaseline = "middle";
ctx.fillText(rpmTxt, g.cx, pillY + 1);
}
// rounded-rect path helper (used for the RPM pills)
function roundRectGT(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 markerColor(g) {
if (g === gears[0]) return "#ff5a2c";
if (g === gears[1]) return "#d98a00";
return "#1463c4";
}
// Build a gear outline path with trapezoidal teeth.
function drawGearPath(teeth, outerR, rootR, rotation) {
ctx.beginPath();
var step = (Math.PI * 2) / teeth;
// each tooth: root-rise-tip-tip-fall-root, expressed as fractions of pitch
var toothTop = 0.30; // fraction of pitch the tooth tip spans
var toothBot = 0.52; // fraction of pitch the tooth base spans
for (var i = 0; i < teeth; i++) {
var a = rotation + i * step;
var aRootStart = a - step * (toothBot / 2);
var aTipStart = a - step * (toothTop / 2);
var aTipEnd = a + step * (toothTop / 2);
var aRootEnd = a + step * (toothBot / 2);
var aValley = a + step / 2;
pt(rootR, aRootStart, i === 0, true);
pt(outerR, aTipStart, false, false);
pt(outerR, aTipEnd, false, false);
pt(rootR, aRootEnd, false, false);
pt(rootR, aValley, false, false);
}
ctx.closePath();
}
function pt(r, ang, isFirst, moveOnly) {
var x = Math.cos(ang) * r;
var y = Math.sin(ang) * r;
if (isFirst) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// ---- color helpers ----
function hexToRgb(hex) {
var h = hex.replace("#", "");
return [parseInt(h.substr(0, 2), 16), parseInt(h.substr(2, 2), 16), parseInt(h.substr(4, 2), 16)];
}
function lighten(hex, amt) {
var c = hexToRgb(hex);
return "rgb(" + Math.min(255, Math.round(c[0] + (255 - c[0]) * amt)) + "," +
Math.min(255, Math.round(c[1] + (255 - c[1]) * amt)) + "," +
Math.min(255, Math.round(c[2] + (255 - c[2]) * amt)) + ")";
}
function darken(hex, amt) {
var c = hexToRgb(hex);
return "rgb(" + Math.round(c[0] * (1 - amt)) + "," +
Math.round(c[1] * (1 - amt)) + "," +
Math.round(c[2] * (1 - amt)) + ")";
}
// ---- render ----
function draw() {
// background
var bg = ctx.createRadialGradient(W * 0.5, H * 0.4, 0, W * 0.5, H * 0.5, Math.max(W, H) * 0.8);
bg.addColorStop(0, "#2a1c0a");
bg.addColorStop(1, "#140d04");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
// subtle bolt-grid texture
ctx.fillStyle = "rgba(255,178,77,0.05)";
var gridGap = 54;
for (var gx = gridGap / 2; gx < W; gx += gridGap) {
for (var gy = gridGap / 2; gy < H; gy += gridGap) {
ctx.beginPath();
ctx.arc(gx, gy, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
// mounting pegs (axles) behind gears
for (var i = 0; i < gears.length; i++) {
var g = gears[i];
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.beginPath();
ctx.arc(g.cx, g.cy, 6, 0, Math.PI * 2);
ctx.fill();
}
// draw big gears first so small ones sit on top at mesh points
for (var d = 0; d < gears.length; d++) {
drawGear(gears[d]);
}
// hand crank sits on top of the big gear
drawCrank();
}
// ---- hand crank ----
// A bright handle bolted to the big gear's hub. Kids grab the round grip and
// turn it like a real crank โ it drives the whole train.
function drawCrank() {
var big = gears[0];
var gp = crankGripPos();
// shadow
ctx.save();
ctx.lineCap = "round";
ctx.lineWidth = Math.max(7, crank.gripR * 0.7);
ctx.strokeStyle = "rgba(0,0,0,0.4)";
ctx.beginPath();
ctx.moveTo(big.cx + 2, big.cy + 4);
ctx.lineTo(gp.x + 2, gp.y + 4);
ctx.stroke();
// crank arm
var armGrad = ctx.createLinearGradient(big.cx, big.cy, gp.x, gp.y);
armGrad.addColorStop(0, "#ff7a3c");
armGrad.addColorStop(1, "#d8431f");
ctx.strokeStyle = armGrad;
ctx.lineWidth = Math.max(6, crank.gripR * 0.6);
ctx.beginPath();
ctx.moveTo(big.cx, big.cy);
ctx.lineTo(gp.x, gp.y);
ctx.stroke();
// hub bolt where the arm meets the gear center
ctx.fillStyle = "#3a2510";
ctx.beginPath();
ctx.arc(big.cx, big.cy, Math.max(5, crank.gripR * 0.35), 0, Math.PI * 2);
ctx.fill();
// round grip knob the kid grabs
var knob = ctx.createRadialGradient(
gp.x - crank.gripR * 0.35, gp.y - crank.gripR * 0.35, 1,
gp.x, gp.y, crank.gripR);
knob.addColorStop(0, "#ffe6b8");
knob.addColorStop(0.55, "#ff9a4c");
knob.addColorStop(1, "#c2391a");
ctx.fillStyle = knob;
ctx.beginPath();
ctx.arc(gp.x, gp.y, crank.gripR, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "#7c2410";
ctx.stroke();
// a soft pulsing ring on the grip when nothing is moving โ invites a turn
var resting = !dragging && Math.abs(driverSpeed) < 0.04 &&
Math.abs(gears[0].rpm) < 1;
if (resting) {
var pulse = (Math.sin(performance.now() * 0.005) + 1) / 2;
ctx.lineWidth = 2.5;
ctx.strokeStyle = "rgba(255,230,184," + (0.25 + 0.45 * pulse) + ")";
ctx.beginPath();
ctx.arc(gp.x, gp.y, crank.gripR + 5 + pulse * 5, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
// ---- ratio display ----
function updateRatio() {
var big = gears[0], small = gears[2];
var ratio = big.teeth / small.teeth;
var rounded = Math.round(ratio * 10) / 10;
ratioEl.innerHTML = "<b>1 : " + rounded + "</b> โ the small gear spins " + rounded + "ร faster";
}
// ---- RPM tracking ----
// We measure how fast the driver angle is actually changing each frame so the
// numbers are honest whether the kid is dragging, cranking, or coasting.
var prevDriverAngle = 0;
function updateRPM(dt) {
if (dt <= 0) return;
var delta = driverAngle - prevDriverAngle;
prevDriverAngle = driverAngle;
var driverRPM = (delta / dt) / (Math.PI * 2) * 60;
for (var i = 0; i < gears.length; i++) {
var g = gears[i];
// each gear's speed = driver speed * its multiplier
var instRPM = driverRPM * g.speedMul;
// smooth so the readout doesn't flicker
g.rpm = g.rpm * 0.78 + instRPM * 0.22;
if (Math.abs(g.rpm) < 0.5) g.rpm = 0;
}
}
// ---- main loop ----
var lastT = 0;
function frame(t) {
var dt = lastT ? Math.min(0.05, (t - lastT) / 1000) : 0.016;
lastT = t;
if (!dragging) {
// momentum + gentle friction
driverAngle += driverSpeed * dt;
driverSpeed *= Math.pow(0.55, dt); // friction
if (Math.abs(driverSpeed) < 0.02) driverSpeed = 0;
applyTrain(driverAngle);
}
updateRPM(dt);
draw();
requestAnimationFrame(frame);
}
// ---- buttons ----
// Rebuild the train keeping the current spin so size changes feel smooth.
function rebuildKeepingSpin() {
var keptSpeed = driverSpeed;
var keptAngle = driverAngle;
buildTrain();
layout();
computePhases();
driverAngle = keptAngle;
driverSpeed = keptSpeed;
prevDriverAngle = keptAngle;
applyTrain(driverAngle);
updateRatio();
updateSizeButtons();
}
function updateSizeButtons() {
smallerBtn.disabled = smallTeeth <= SMALL_MIN;
biggerBtn.disabled = smallTeeth >= SMALL_MAX;
}
var smallerBtn = document.getElementById("smaller");
var biggerBtn = document.getElementById("bigger");
smallerBtn.addEventListener("click", function () {
if (smallTeeth <= SMALL_MIN) return;
smallTeeth -= 2;
rebuildKeepingSpin();
});
biggerBtn.addEventListener("click", function () {
if (smallTeeth >= SMALL_MAX) return;
smallTeeth += 2;
rebuildKeepingSpin();
});
document.getElementById("reset").addEventListener("click", function () {
driverAngle = 0;
driverSpeed = 0;
prevDriverAngle = 0;
dragging = null;
dragPointerId = null;
smallTeeth = SMALL_DEFAULT;
buildTrain();
layout();
computePhases();
applyTrain(driverAngle);
updateRatio();
updateSizeButtons();
});
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 ----
buildTrain();
resize();
updateRatio();
updateSizeButtons();
requestAnimationFrame(frame);
requestAnimationFrame(function () { stage.classList.add("ready"); });
})();
</script>
</body>
</html>
Game Source: Gear Train
Creator: ArcadeLab
Libraries: none
Complexity: complex (868 lines, 27.5 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: gear-train-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.