13

Roller Coaster Energy: PE + KE = Constant

drag the track handles to reshape the hills · toggle friction

A roller coaster energy simulation: a cart coasts along a spline track driven only by gravity, while live stacked bars show potential energy trading against kinetic energy — their sum stays pinned at the same total . Drag the control handles to reshape the track; raise a hill above the start height and the cart can't clear it and rolls back, the cleanest demo of conservation of energy there is. Toggle friction on and watch a red 'lost' band eat the total.

idle
154 lines · vanilla
view source
const G = 9.8, NPTS = 6, SAMP = 36, NS = (NPTS - 1) * SAMP + 1;
const BTN = 44, PAD = 12, TRAIL = 100, MU = 0.18;
let W, H, scale, groundY, total;
let px, py, sx, sy, slen;
let cartS, cartV, dragIdx, wasDown, fricOn, lost, flagT;
let tx, ty, tv, tIdx, tCnt, cols;
let gx, gy, gdx, gdy;

function init({ width, height }) {
  W = width; H = height;
  px = [0.05, 0.22, 0.42, 0.60, 0.78, 0.95];
  py = [0.20, 0.62, 0.34, 0.70, 0.48, 0.26];
  sx = new Float32Array(NS); sy = new Float32Array(NS); slen = new Float32Array(NS);
  tx = new Float32Array(TRAIL); ty = new Float32Array(TRAIL); tv = new Float32Array(TRAIL);
  tIdx = 0; tCnt = 0; cartS = 1; cartV = 0;
  dragIdx = -1; wasDown = false; fricOn = false; lost = 0; flagT = 0;
  cols = [];
  for (let i = 0; i < 16; i++) cols.push("hsl(" + (210 - i * 13) + ",95%,62%)");
  layout(); buildTrack();
}
function layout() { scale = H / 11; groundY = H - 12; }
function cr(a, b, c, d, t) {
  const t2 = t * t, t3 = t2 * t;
  return 0.5 * (2 * b + (c - a) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (3 * b - a - 3 * c + d) * t3);
}
function buildTrack() {
  let k = 0;
  for (let i = 0; i < NPTS - 1; i++) {
    const a = Math.max(i - 1, 0), d = Math.min(i + 2, NPTS - 1);
    const n = i === NPTS - 2 ? SAMP + 1 : SAMP;
    for (let j = 0; j < n; j++, k++) {
      const t = j / SAMP;
      sx[k] = cr(px[a], px[i], px[i + 1], px[d], t) * W;
      sy[k] = cr(py[a], py[i], py[i + 1], py[d], t) * H;
    }
  }
  slen[0] = 0;
  for (let i = 1; i < NS; i++)
    slen[i] = slen[i - 1] + Math.hypot(sx[i] - sx[i - 1], sy[i] - sy[i - 1]);
  total = slen[NS - 1];
  if (cartS > total) cartS = total;
}
function trackAt(s) {
  if (s < 0) s = 0; if (s > total) s = total;
  let lo = 0, hi = NS - 1;
  while (hi - lo > 1) { const m = (lo + hi) >> 1; if (slen[m] <= s) lo = m; else hi = m; }
  const ds = (slen[hi] - slen[lo]) || 1e-6, f = (s - slen[lo]) / ds;
  gx = sx[lo] + (sx[hi] - sx[lo]) * f; gy = sy[lo] + (sy[hi] - sy[lo]) * f;
  gdx = (sx[hi] - sx[lo]) / ds; gdy = (sy[hi] - sy[lo]) / ds;
}
function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; layout(); buildTrack(); }
  const mx = input.mouseX, my = input.mouseY, down = input.mouseDown;
  const fbx = W - PAD - BTN, fby = H - PAD - BTN;
  if (down && !wasDown && dragIdx < 0)
    for (let i = 0; i < NPTS; i++)
      if (Math.hypot(mx - px[i] * W, my - py[i] * H) < 28) { dragIdx = i; break; }
  if (!down) dragIdx = -1;
  if (dragIdx >= 0) {
    px[dragIdx] = Math.min(0.97, Math.max(0.03, mx / W));
    py[dragIdx] = Math.min(0.90, Math.max(0.05, my / H));
    buildTrack();
  }
  wasDown = down;
  for (const c of input.consumeClicks())
    if (c.x >= fbx && c.x <= fbx + BTN && c.y >= fby && c.y <= fby + BTN) fricOn = !fricOn;

  const sub = 4, h = Math.min(dt, 0.033) / sub;
  for (let i = 0; i < sub; i++) {
    trackAt(cartS);
    let a = G * scale * gdy;
    if (fricOn && Math.abs(cartV) > 1e-3) {
      a -= Math.sign(cartV) * MU * G * scale * Math.abs(gdx);
      lost += MU * G * Math.abs(gdx) * Math.abs(cartV / scale) * h;
    }
    const vp = cartV;
    cartV += a * h; cartS += cartV * h;
    if (cartS <= 0) { cartS = 0; cartV = Math.abs(cartV); }
    else if (cartS >= total) { cartS = total; cartV = -Math.abs(cartV); }
    else if (vp * cartV < 0 && Math.abs(gdy) > 0.12) flagT = 1.8;
  }
  flagT = Math.max(0, flagT - dt);
  trackAt(cartS);
  tx[tIdx] = gx; ty[tIdx] = gy; tv[tIdx] = Math.abs(cartV) / scale;
  tIdx = (tIdx + 1) % TRAIL; if (tCnt < TRAIL) tCnt++;
  const vm = Math.abs(cartV) / scale, hm = (groundY - gy) / scale;
  const ke = 0.5 * vm * vm, pe = G * hm, Et = ke + pe + lost;

  ctx.fillStyle = "#070a12"; ctx.fillRect(0, 0, W, H);
  ctx.fillStyle = "#0c1322"; ctx.fillRect(0, groundY, W, H - groundY);
  ctx.lineJoin = "round"; ctx.lineCap = "round";
  ctx.beginPath(); ctx.moveTo(sx[0], sy[0]);
  for (let i = 1; i < NS; i++) ctx.lineTo(sx[i], sy[i]);
  ctx.strokeStyle = "#26304a"; ctx.lineWidth = 7; ctx.stroke();
  ctx.strokeStyle = "#90a6cf"; ctx.lineWidth = 2.5; ctx.stroke();
  for (let i = 0; i < tCnt; i++) {
    const idx = (tIdx - tCnt + i + 2 * TRAIL) % TRAIL;
    ctx.globalAlpha = 0.08 + 0.7 * (i / tCnt);
    ctx.fillStyle = cols[Math.min(15, (tv[idx] * 1.2) | 0)];
    ctx.fillRect(tx[idx] - 2, ty[idx] - 2, 4, 4);
  }
  ctx.globalAlpha = 1;
  ctx.fillStyle = "rgba(255,200,100,0.22)";
  ctx.beginPath(); ctx.arc(gx, gy, 17, 0, 6.2832); ctx.fill();
  for (let i = 0; i < NPTS; i++) {
    const hx = px[i] * W, hy = py[i] * H, hot = dragIdx === i;
    ctx.strokeStyle = hot ? "#ffb84d" : "rgba(255,255,255,0.4)";
    ctx.lineWidth = hot ? 3 : 1.5;
    ctx.beginPath(); ctx.arc(hx, hy, 14, 0, 6.2832); ctx.stroke();
    ctx.fillStyle = hot ? "#ffb84d" : "#dfe7f5";
    ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
  }
  ctx.save();
  ctx.translate(gx, gy); ctx.rotate(Math.atan2(gdy, gdx));
  ctx.fillStyle = cols[Math.min(15, (vm * 1.2) | 0)];
  ctx.fillRect(-13, -14, 26, 11);
  ctx.fillStyle = "#222a3d";
  ctx.beginPath(); ctx.arc(-7, -2, 4, 0, 6.2832); ctx.arc(7, -2, 4, 0, 6.2832); ctx.fill();
  ctx.restore();

  const bw = 26, bh = H * 0.42, bx = PAD, by = H * 0.30;
  const fk = ke / Et, fp = pe / Et, fl = lost / Et;
  ctx.fillStyle = "rgba(0,0,0,0.5)"; ctx.fillRect(bx - 4, by - 4, bw + 8, bh + 8);
  ctx.fillStyle = "#ff9a3c"; ctx.fillRect(bx, by + bh * (1 - fk), bw, bh * fk);
  ctx.fillStyle = "#3ecf6a"; ctx.fillRect(bx, by + bh * fl, bw, bh * fp);
  ctx.fillStyle = "#e0454f"; ctx.fillRect(bx, by, bw, bh * fl);
  ctx.strokeStyle = "rgba(255,255,255,0.6)"; ctx.lineWidth = 1.5;
  ctx.strokeRect(bx - 0.5, by - 0.5, bw + 1, bh + 1);
  ctx.font = "11px monospace"; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
  ctx.fillStyle = "#3ecf6a"; ctx.fillText("PE", bx + bw + 7, by + bh * (fl + fp * 0.5) + 4);
  ctx.fillStyle = "#ff9a3c"; ctx.fillText("KE", bx + bw + 7, by + bh * (1 - fk * 0.5) + 4);
  if (fl > 0.03) { ctx.fillStyle = "#e0454f"; ctx.fillText("lost", bx + bw + 7, by + bh * fl * 0.5 + 4); }

  ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(PAD, PAD, 190, 96);
  ctx.fillStyle = "#fff"; ctx.font = "13px monospace";
  ctx.fillText("v  = " + vm.toFixed(2) + " m/s", PAD + 10, PAD + 19);
  ctx.fillText("h  = " + hm.toFixed(2) + " m", PAD + 10, PAD + 36);
  ctx.fillText("KE = " + ke.toFixed(1) + "  PE = " + pe.toFixed(1), PAD + 10, PAD + 53);
  ctx.fillText("E  = " + Et.toFixed(1) + " J/kg", PAD + 10, PAD + 70);
  ctx.fillStyle = fricOn ? "#e0454f" : "#7f8aa3";
  ctx.fillText("friction " + (fricOn ? "ON" : "off"), PAD + 10, PAD + 87);
  if (flagT > 0) {
    ctx.fillStyle = "rgba(224,69,79," + Math.min(1, flagT).toFixed(2) + ")";
    ctx.font = "bold 15px monospace";
    ctx.fillText("insufficient energy! rolls back", PAD, PAD + 118);
  }
  ctx.fillStyle = fricOn ? "rgba(224,69,79,0.85)" : "rgba(0,0,0,0.65)";
  ctx.fillRect(fbx, fby, BTN, BTN);
  ctx.strokeStyle = "rgba(255,255,255,0.45)"; ctx.lineWidth = 1;
  ctx.strokeRect(fbx + 0.5, fby + 0.5, BTN - 1, BTN - 1);
  ctx.fillStyle = "#fff"; ctx.font = "bold 22px ui-sans-serif, system-ui";
  ctx.textAlign = "center"; ctx.textBaseline = "middle";
  ctx.fillText("μ", fbx + BTN / 2, fby + BTN / 2);
  ctx.font = "10px monospace"; ctx.textAlign = "right"; ctx.textBaseline = "alphabetic";
  ctx.fillStyle = "rgba(255,255,255,0.7)";
  ctx.fillText("friction", fbx + BTN, fby - 6);
  ctx.fillText("drag handles to reshape track", W - PAD, PAD + 12);
  ctx.textAlign = "left";
}

Comments (0)

Log in to comment.