13
Roller Coaster Energy: PE + KE = Constant
drag the track handles to reshape the hills · toggle friction
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.