Pulley Lifter
by ArcadeLab942 lines30.9 KB
<!--ARCADELAB
title: Pulley Lifter
description: A hands-on machines sandbox — thread 1, 2, or 3 pulleys, then drag the rope to lift a heavy crate. Watch force arrows, see your pull force in kg, the rope length you pulled, and a confetti win when the crate reaches the top.
emoji: 🏗️
color: teal
-->
<!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>Pulley Lifter</title>
<style>
:root {
--bg: #04161a;
--panel: rgba(8, 42, 48, 0.86);
--accent: #2fd6c4;
--accent2: #7af0dd;
--text: #e6fffb;
--muted: #9ed6cf;
}
* { 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);
letter-spacing: 0.2px;
background: var(--panel);
border: 1px solid rgba(47, 214, 196, 0.30);
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: 700;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(47, 214, 196, 0.34);
border-radius: 13px;
padding: 11px 15px;
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(14, 64, 70, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
button.on {
background: linear-gradient(180deg, #2fd6c4, #14a394);
color: #04231f;
border-color: var(--accent2);
}
.pgroup {
display: flex;
gap: 6px;
background: var(--panel);
border: 1px solid rgba(47, 214, 196, 0.30);
border-radius: 15px;
padding: 5px;
pointer-events: auto;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.pgroup button {
background: transparent;
border: none;
min-width: 46px;
padding: 9px 12px;
}
.pgroup button:hover { background: rgba(47, 214, 196, 0.18); }
#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;
}
#hint {
background: var(--panel);
border: 1px solid rgba(47, 214, 196, 0.22);
border-radius: 13px;
padding: 8px 14px;
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
text-align: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
max-width: 92vw;
}
#howBtn { pointer-events: auto; }
#howPanel {
pointer-events: auto;
display: none;
background: var(--panel);
border: 1px solid rgba(47, 214, 196, 0.30);
border-radius: 14px;
padding: 14px 16px;
max-width: min(440px, 92vw);
font-size: clamp(13px, 3.5vw, 15px);
line-height: 1.5;
color: var(--text);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#howPanel.show { display: block; }
#howPanel h3 { margin: 0 0 6px; font-size: 1.05em; color: var(--accent2); }
#howPanel p { margin: 6px 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>Pulley Lifter</span></div>
<div class="spacer"></div>
<div class="pgroup">
<button id="p1" type="button">1</button>
<button id="p2" type="button">2</button>
<button id="p3" type="button">3</button>
</div>
<div class="controls">
<button id="reset" type="button">Reset</button>
</div>
</div>
<div id="info">
<div id="howPanel">
<h3>How it works</h3>
<p>A pulley is a wheel that changes the way you pull a rope.</p>
<p>The <b>blue arrow</b> is the crate's heavy weight pulling down. The <b>orange arrow</b> is how hard <b>you</b> pull. More pulleys make your arrow shorter!</p>
<p>With more pulleys sharing the load, you pull with <b>less force</b> — but you must pull <b>more rope</b>. The meters show your pull in kg and how much rope you've pulled.</p>
<p>2 pulleys = half the pull. 3 pulleys = a third of the pull. Lift it all the way for a confetti cheer!</p>
</div>
<div id="hint">Drag the orange rope handle down to lift the crate</div>
<button id="howBtn" type="button">How it works</button>
</div>
<script>
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var hintEl = document.getElementById("hint");
var W = 0, H = 0, DPR = 1;
// ---- simulation state ----
var nPulleys = 2; // 1, 2, or 3
var lift = 0; // crate lift fraction 0..1 (0 = floor, 1 = top)
var liftVel = 0; // smoothing velocity for the crate
var ropePulled = 0; // total rope length consumed by current lift (px-ish)
// dragging the rope handle
var dragging = false;
var dragPointerId = null;
var dragStartY = 0; // pointer y at grab
var dragStartLift = 0; // lift value at grab
var handleHotR = 46; // generous tap radius
// pulse for "lifted" celebration
var celebrate = 0;
var wonOnce = false; // has the crate been fully lifted this run?
var confetti = []; // confetti particles for the win moment
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
// spawn a burst of confetti when the crate reaches the top
function spawnConfetti(cx, cy) {
var colors = ["#2fd6c4", "#7af0dd", "#f4a23c", "#ffce7a", "#fff4e0"];
for (var i = 0; i < 46; i++) {
var ang = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.3;
var sp = 120 + Math.random() * 280;
confetti.push({
x: cx + (Math.random() - 0.5) * 40,
y: cy,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp,
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 12,
size: 5 + Math.random() * 6,
color: colors[(Math.random() * colors.length) | 0],
life: 1
});
}
}
// ---- 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);
}
// ---- geometry helpers (computed each frame from W/H) ----
// Returns the layout object describing where everything is drawn.
function layout() {
var narrow = W < 560;
// bottom space reserved for the HTML hint + "How it works" controls
var bottomInset = narrow ? 118 : 92;
// meters panel size
var panelW = narrow ? clamp(W - 28, 200, 340) : clamp(W * 0.42, 168, 240);
var panelH = narrow ? 96 : 132;
var topY = Math.max(86, H * 0.15); // ceiling beam y
// floor leaves room below for the meters panel + bottom HTML controls
var floorY = H - bottomInset - panelH - 18;
floorY = clamp(floorY, topY + 220, H * 0.82);
var travel = floorY - topY - 150; // how far the crate can rise
travel = Math.max(80, travel);
var crateSize = clamp(Math.min(W, H) * 0.20, 64, 150);
var pulleyR = clamp(Math.min(W, H) * 0.045, 15, 30);
// crate centered horizontally a bit left of middle
var crateCX = narrow ? W * 0.36 : W * 0.40;
var crateTopY = floorY - crateSize - lift * travel; // top of crate
var crateCY = crateTopY + crateSize / 2;
// fixed (ceiling) pulleys spread to the right of the crate
var fixedCX = narrow ? W * 0.60 : W * 0.66;
// moving block sits just above the crate
var movingCX = crateCX;
var movingY = crateTopY - pulleyR - 14;
// the "effort" rope hangs down on the right, kept clear of the edge
var handleHalf = 30;
var pullX = clamp(W * 0.86, fixedCX + pulleyR * 2 + 34, W - handleHalf - 14);
return {
narrow: narrow, bottomInset: bottomInset,
panelW: panelW, panelH: panelH,
topY: topY, floorY: floorY, travel: travel,
crateSize: crateSize, pulleyR: pulleyR,
crateCX: crateCX, crateCY: crateCY, crateTopY: crateTopY,
fixedCX: fixedCX, movingCX: movingCX, movingY: movingY,
pullX: pullX
};
}
// The rope handle position depends on how much rope is pulled.
// More pulleys => the handle travels nPulleys x as far for the same lift.
function handlePos(L) {
var travelLen = L.floorY - L.topY - 130;
travelLen = Math.max(60, travelLen);
// resting handle position near the top of the pull rope
var restY = L.topY + L.pulleyR + 18;
var hy = restY + lift * travelLen * nPulleys;
// keep handle on screen
var maxY = L.floorY - 30;
hy = clamp(hy, restY, maxY);
return { x: L.pullX, y: hy, restY: restY, travelLen: travelLen };
}
// ---- physics-ish update ----
function update(dt) {
// (lift itself is set by dragging; here we just animate celebrate)
if (celebrate > 0) celebrate = Math.max(0, celebrate - dt);
// advance confetti
for (var i = confetti.length - 1; i >= 0; i--) {
var p = confetti[i];
p.vy += 620 * dt; // gravity
p.x += p.vx * dt;
p.y += p.vy * dt;
p.rot += p.vr * dt;
p.life -= dt * 0.55;
if (p.life <= 0 || p.y > H + 40) confetti.splice(i, 1);
}
}
// ---- rendering ----
function roundRect(x, y, w, h, r) {
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();
}
function drawPulley(x, y, r, spin) {
// outer wheel
var g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 1, x, y, r);
g.addColorStop(0, "#cdeeea");
g.addColorStop(1, "#5a8f89");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
// rim
ctx.lineWidth = Math.max(2, r * 0.16);
ctx.strokeStyle = "#2fd6c4";
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.stroke();
// groove ring
ctx.lineWidth = Math.max(1, r * 0.08);
ctx.strokeStyle = "rgba(4,35,32,0.45)";
ctx.beginPath();
ctx.arc(x, y, r * 0.7, 0, Math.PI * 2);
ctx.stroke();
// spinning hub spokes
ctx.save();
ctx.translate(x, y);
ctx.rotate(spin);
ctx.strokeStyle = "rgba(4,35,32,0.55)";
ctx.lineWidth = Math.max(2, r * 0.13);
ctx.lineCap = "round";
for (var i = 0; i < 4; i++) {
ctx.rotate(Math.PI / 2);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r * 0.55, 0);
ctx.stroke();
}
ctx.restore();
// center bolt
ctx.fillStyle = "#04231f";
ctx.beginPath();
ctx.arc(x, y, r * 0.16, 0, Math.PI * 2);
ctx.fill();
}
// draw a rope segment with a soft highlight
function ropeSeg(ax, ay, bx, by) {
ctx.lineCap = "round";
ctx.strokeStyle = "#f4a23c";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
ctx.strokeStyle = "rgba(255,230,180,0.7)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
// draw a thick force arrow from (x,y) pointing (dx,dy) with given length.
// label is drawn next to the arrowhead.
function forceArrow(x, y, dirX, dirY, len, color, label) {
var mag = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
var ux = dirX / mag, uy = dirY / mag;
var ex = x + ux * len, ey = y + uy * len;
ctx.lineCap = "round";
// shaft
ctx.strokeStyle = color;
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex - ux * 9, ey - uy * 9);
ctx.stroke();
// arrowhead
var ah = 15;
var ang = Math.atan2(uy, ux);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - ah * Math.cos(ang - 0.45), ey - ah * Math.sin(ang - 0.45));
ctx.lineTo(ex - ah * Math.cos(ang + 0.45), ey - ah * Math.sin(ang + 0.45));
ctx.closePath();
ctx.fill();
if (label) {
ctx.font = "700 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var lx = ex + ux * 4, ly = ey + uy * 4;
// nudge label clear of the arrowhead
lx += (Math.abs(ux) > Math.abs(uy)) ? ux * 22 : 0;
ly += (Math.abs(uy) >= Math.abs(ux)) ? uy * 16 : 0;
ctx.fillStyle = "rgba(4,22,26,0.85)";
var tw = ctx.measureText(label).width;
roundRect(lx - tw / 2 - 6, ly - 10, tw + 12, 20, 7);
ctx.fill();
ctx.fillStyle = color;
ctx.fillText(label, lx, ly + 1);
}
}
function drawConfetti() {
for (var i = 0; i < confetti.length; i++) {
var p = confetti[i];
ctx.save();
ctx.globalAlpha = clamp(p.life, 0, 1);
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6);
ctx.restore();
}
ctx.globalAlpha = 1;
}
var spin = 0;
function draw(time) {
// background
ctx.fillStyle = "#04161a";
ctx.fillRect(0, 0, W, H);
var gg = ctx.createRadialGradient(W * 0.5, H * 0.3, 0, W * 0.5, H * 0.3, Math.max(W, H));
gg.addColorStop(0, "rgba(20,80,86,0.55)");
gg.addColorStop(1, "rgba(4,16,19,0)");
ctx.fillStyle = gg;
ctx.fillRect(0, 0, W, H);
var L = layout();
var hp = handlePos(L);
// ---- floor ----
ctx.fillStyle = "#0a3038";
ctx.fillRect(0, L.floorY, W, H - L.floorY);
ctx.strokeStyle = "rgba(47,214,196,0.35)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(0, L.floorY);
ctx.lineTo(W, L.floorY);
ctx.stroke();
// ---- ceiling beam ----
ctx.fillStyle = "#0a3038";
roundRect(W * 0.06, L.topY - 22, W * 0.88, 22, 6);
ctx.fill();
ctx.strokeStyle = "rgba(47,214,196,0.4)";
ctx.lineWidth = 2;
roundRect(W * 0.06, L.topY - 22, W * 0.88, 22, 6);
ctx.stroke();
// beam rivets
ctx.fillStyle = "rgba(47,214,196,0.45)";
for (var rv = 0; rv < 9; rv++) {
var rx = W * 0.10 + rv * (W * 0.80 / 8);
ctx.beginPath();
ctx.arc(rx, L.topY - 11, 3, 0, Math.PI * 2);
ctx.fill();
}
// ---- compute pulley positions ----
// fixed pulleys hang from the ceiling near fixedCX
var fixed = [];
var moving = [];
var gap = L.pulleyR * 2.4;
var nFixed = nPulleys; // fixed (top) pulleys
var nMoving = nPulleys - 1; // moving (bottom) pulleys
var i;
for (i = 0; i < nFixed; i++) {
fixed.push({ x: L.fixedCX + (i - (nFixed - 1) / 2) * gap, y: L.topY + L.pulleyR });
}
for (i = 0; i < nMoving; i++) {
moving.push({ x: L.movingCX + (i - (nMoving - 1) / 2) * gap * 0.7, y: L.movingY });
}
// ---- the crate hangs from the moving block (or directly from a fixed pulley for n=1) ----
var cs = L.crateSize;
var cx = L.crateCX;
var crateTop = L.crateTopY;
// ---- draw rope threading ----
// For n pulleys the rope path:
// - one end anchored to ceiling (n>=2) or it's a simple loop for n=1
// - threads alternately around moving and fixed pulleys
// - free end goes over the last fixed pulley and down to the handle
if (nPulleys === 1) {
// single fixed pulley: rope from crate up over pulley, down to handle
var fp = fixed[0];
// crate attachment point
ropeSeg(cx, crateTop, fp.x - L.pulleyR, fp.y);
// around the pulley (arc)
ctx.strokeStyle = "#f4a23c";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(fp.x, fp.y, L.pulleyR, Math.PI, Math.PI * 2);
ctx.stroke();
// down to handle
ropeSeg(fp.x + L.pulleyR, fp.y, hp.x, hp.y);
} else {
// n>=2: anchor on ceiling, zigzag through moving + fixed pulleys
var anchorX = L.movingCX - cs * 0.5 - 6;
var anchorY = L.topY;
// build the list of contact points in order
// anchor -> moving[0] -> fixed[0] -> moving[1] -> fixed[1] ... -> last fixed -> handle
var prevX = anchorX, prevY = anchorY;
// anchor block on ceiling
ctx.fillStyle = "#0a3038";
roundRect(anchorX - 9, anchorY - 4, 18, 12, 3);
ctx.fill();
ctx.strokeStyle = "rgba(47,214,196,0.5)";
ctx.lineWidth = 2;
roundRect(anchorX - 9, anchorY - 4, 18, 12, 3);
ctx.stroke();
ctx.strokeStyle = "#f4a23c";
var step;
// sequence of pulleys: alternate moving / fixed
var seq = [];
for (i = 0; i < nMoving; i++) {
seq.push(moving[i]);
if (i < nFixed) seq.push(fixed[i]);
}
// any remaining fixed pulleys
// (with nFixed = nMoving + 1, exactly one fixed pulley remains)
// it is fixed[nFixed-1]
// draw straight rope between consecutive contact points, and arcs around pulleys
// segment: anchor -> moving[0] left side
for (step = 0; step < seq.length; step++) {
var pu = seq[step];
var isMoving = (pu.y === L.movingY);
// approach side: rope comes from prev; leaves toward next.
// we just draw a tangent line to pulley side and a wrap arc.
// determine which side rope touches based on direction
var fromLeft = prevX < pu.x;
var sideX = pu.x + (fromLeft ? -L.pulleyR : L.pulleyR);
ropeSeg(prevX, prevY, sideX, pu.y);
// wrap arc around the pulley (half loop)
ctx.strokeStyle = "#f4a23c";
ctx.lineWidth = 6;
ctx.beginPath();
if (isMoving) {
// moving pulley: rope wraps the BOTTOM half
ctx.arc(pu.x, pu.y, L.pulleyR, 0, Math.PI);
} else {
// fixed pulley: rope wraps the TOP half
ctx.arc(pu.x, pu.y, L.pulleyR, Math.PI, Math.PI * 2);
}
ctx.stroke();
ctx.strokeStyle = "rgba(255,230,180,0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
if (isMoving) ctx.arc(pu.x, pu.y, L.pulleyR, 0, Math.PI);
else ctx.arc(pu.x, pu.y, L.pulleyR, Math.PI, Math.PI * 2);
ctx.stroke();
// exit side
prevX = pu.x + (fromLeft ? L.pulleyR : -L.pulleyR);
prevY = pu.y;
}
// from the last contact point, go to the last fixed pulley, then down to handle
var lastFixed = fixed[nFixed - 1];
var fromLeft2 = prevX < lastFixed.x;
var sideX2 = lastFixed.x + (fromLeft2 ? -L.pulleyR : L.pulleyR);
ropeSeg(prevX, prevY, sideX2, lastFixed.y);
ctx.strokeStyle = "#f4a23c";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.arc(lastFixed.x, lastFixed.y, L.pulleyR, Math.PI, Math.PI * 2);
ctx.stroke();
// down to handle
ropeSeg(lastFixed.x + L.pulleyR, lastFixed.y, hp.x, hp.y);
// rope from moving block down to the crate
for (i = 0; i < nMoving; i++) {
ropeSeg(moving[i].x, moving[i].y + L.pulleyR, moving[i].x, crateTop);
}
}
// ---- moving block bar (connects moving pulleys) ----
if (nMoving >= 1) {
var mLx = moving[0].x, mRx = moving[nMoving - 1].x;
ctx.strokeStyle = "#2fd6c4";
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(mLx, L.movingY);
ctx.lineTo(mRx, L.movingY);
ctx.stroke();
}
// ---- draw pulleys ----
spin += 0.02;
for (i = 0; i < fixed.length; i++) drawPulley(fixed[i].x, fixed[i].y, L.pulleyR, spin);
for (i = 0; i < moving.length; i++) drawPulley(moving[i].x, moving[i].y, L.pulleyR, -spin);
// ---- the crate ----
var crateGrad = ctx.createLinearGradient(cx - cs / 2, crateTop, cx + cs / 2, crateTop + cs);
crateGrad.addColorStop(0, "#d98c2e");
crateGrad.addColorStop(1, "#a85f12");
ctx.fillStyle = crateGrad;
roundRect(cx - cs / 2, crateTop, cs, cs, 12);
ctx.fill();
ctx.strokeStyle = "#ffd089";
ctx.lineWidth = 3;
roundRect(cx - cs / 2, crateTop, cs, cs, 12);
ctx.stroke();
// crate cross planks
ctx.strokeStyle = "rgba(255,225,170,0.55)";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(cx - cs / 2 + 8, crateTop + 8);
ctx.lineTo(cx + cs / 2 - 8, crateTop + cs - 8);
ctx.moveTo(cx + cs / 2 - 8, crateTop + 8);
ctx.lineTo(cx - cs / 2 + 8, crateTop + cs - 8);
ctx.stroke();
// weight label
ctx.fillStyle = "#fff4e0";
ctx.font = "700 " + Math.round(cs * 0.20) + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("100", cx, crateTop + cs * 0.42);
ctx.font = "700 " + Math.round(cs * 0.13) + "px system-ui, sans-serif";
ctx.fillText("KG", cx, crateTop + cs * 0.66);
// celebration sparkle when fully lifted
if (lift > 0.97) {
celebrate = 0.6;
if (!wonOnce) {
wonOnce = true;
spawnConfetti(cx, crateTop + cs * 0.3);
hintEl.textContent = "Yes! The crate is all the way up!";
}
} else if (lift < 0.9) {
wonOnce = false;
}
if (celebrate > 0) {
ctx.fillStyle = "rgba(122,240,221," + (celebrate * 0.9) + ")";
for (i = 0; i < 7; i++) {
var a = (time * 0.004) + i * (Math.PI * 2 / 7);
var sr = cs * 0.7 + (0.6 - celebrate) * 60;
var sx = cx + Math.cos(a) * sr;
var sy = crateTop + cs * 0.4 + Math.sin(a) * sr;
ctx.beginPath();
ctx.arc(sx, sy, 4, 0, Math.PI * 2);
ctx.fill();
}
}
// ---- force arrows ----
// The crate's full weight (100 kg) pulls straight down. Anchored on the
// crate's right side and clamped so it never reaches the floor / meters.
var weightLen = clamp(cs * 0.52, 38, 78);
var wArrowX = cx + cs / 2 + 26;
if (wArrowX > W - 30) wArrowX = cx - cs / 2 - 26;
var wArrowY = crateTop + cs * 0.5 - weightLen / 2;
var wMaxBottom = L.floorY - 6;
if (wArrowY + weightLen > wMaxBottom) wArrowY = wMaxBottom - weightLen;
forceArrow(wArrowX, wArrowY, 0, 1, weightLen, "#5fb8ff", "100 kg");
// Your pull: only 1/n of the weight, drawn shorter, pointing down
// (the direction you tug the rope) just to the side of the handle so it
// never collides with the HUD at the top of the screen.
var pullKg = Math.round(100 / nPulleys);
var pullLen = weightLen / nPulleys;
var arrowX = hp.x - 40;
if (arrowX < 26) arrowX = hp.x + 40;
forceArrow(arrowX, hp.y - pullLen / 2, 0, 1, pullLen,
"#f4a23c", pullKg + " kg");
// ---- the rope handle (drag target) ----
var hglow = dragging ? 1 : 0.55 + 0.25 * Math.sin(time * 0.005);
var hg = ctx.createRadialGradient(hp.x, hp.y, 0, hp.x, hp.y, 34);
hg.addColorStop(0, "rgba(244,162,60," + (0.55 * hglow) + ")");
hg.addColorStop(1, "rgba(244,162,60,0)");
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(hp.x, hp.y, 34, 0, Math.PI * 2);
ctx.fill();
// handle bar
var hw = 56, hh = 22;
var handleGrad = ctx.createLinearGradient(0, hp.y - hh / 2, 0, hp.y + hh / 2);
handleGrad.addColorStop(0, "#ffce7a");
handleGrad.addColorStop(1, "#e07f1c");
ctx.fillStyle = handleGrad;
roundRect(hp.x - hw / 2, hp.y - hh / 2, hw, hh, 11);
ctx.fill();
ctx.strokeStyle = "#fff0d4";
ctx.lineWidth = 2.5;
roundRect(hp.x - hw / 2, hp.y - hh / 2, hw, hh, 11);
ctx.stroke();
// grip lines
ctx.strokeStyle = "rgba(80,40,0,0.5)";
ctx.lineWidth = 3;
for (i = -1; i <= 1; i++) {
ctx.beginPath();
ctx.moveTo(hp.x + i * 11, hp.y - hh / 2 + 5);
ctx.lineTo(hp.x + i * 11, hp.y + hh / 2 - 5);
ctx.stroke();
}
// down arrow hint when not dragging and not lifted
if (!dragging && lift < 0.04) {
var ay = hp.y + 30 + 5 * Math.sin(time * 0.006);
ctx.fillStyle = "rgba(244,162,60,0.85)";
ctx.beginPath();
ctx.moveTo(hp.x, ay + 12);
ctx.lineTo(hp.x - 9, ay);
ctx.lineTo(hp.x + 9, ay);
ctx.closePath();
ctx.fill();
}
// ---- meters panel (bottom-left, drawn on canvas) ----
drawMeters(L, time);
// ---- confetti celebration (drawn last, on top of everything) ----
drawConfetti();
}
function drawMeters(L, time) {
var panelW = L.panelW;
var panelH = L.panelH;
// panel sits in the band between the floor and the bottom HTML controls
var px = L.narrow ? (W - panelW) / 2 : 14;
var bandTop = L.floorY + 8;
var bandBot = H - L.bottomInset - 6;
var py = bandTop + Math.max(0, (bandBot - bandTop - panelH) / 2);
if (py + panelH > bandBot) py = bandBot - panelH;
if (py < L.floorY + 6) py = L.floorY + 6;
// panel bg
ctx.fillStyle = "rgba(8,42,48,0.92)";
roundRect(px, py, panelW, panelH, 14);
ctx.fill();
ctx.strokeStyle = "rgba(47,214,196,0.3)";
ctx.lineWidth = 1.5;
roundRect(px, py, panelW, panelH, 14);
ctx.stroke();
var force = 1 / nPulleys; // fraction of load you must pull
var ropeFactor = nPulleys; // rope used per unit lift
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
var labelSize = clamp(W * 0.035, 12, 15);
var valSize = clamp(W * 0.032, 11, 14);
var effColor = force > 0.8 ? "#ff6b5e" : (force > 0.4 ? "#f4a23c" : "#2fd6c4");
// numeric readouts
var pullKg = Math.round(force * 100); // kg you pull
// the crate rises a fixed 3.0 m at full lift; rope pulled = lift x 3 x n
var crateMeters = (lift * 3).toFixed(1); // height gained
var ropeMeters = (lift * 3 * ropeFactor).toFixed(1); // rope you pulled
var ropeFrac = clamp(lift * ropeFactor / 3, 0, 1);
if (L.narrow) {
// two meters side by side
var colW = (panelW - 14 * 3) / 2;
drawMeter(px + 14, py, colW, "YOUR PULL",
force, effColor, pullKg + " kg", "#e6fffb",
labelSize, valSize);
drawMeter(px + 14 * 2 + colW, py, colW, "ROPE PULLED",
ropeFrac, "#7af0dd", ropeMeters + " m", "#04231f",
labelSize, valSize);
} else {
// two meters stacked
drawMeter(px + 14, py + 4, panelW - 28, "YOUR PULL (force)",
force, effColor, pullKg + " kg (lifts 100 kg)", "#e6fffb",
labelSize, valSize);
drawMeter(px + 14, py + panelH / 2 + 4, panelW - 28, "ROPE PULLED",
ropeFrac, "#7af0dd", ropeMeters + " m (crate up " + crateMeters + " m)", "#04231f",
labelSize, valSize);
}
}
// draw one labelled bar meter; (bx,by) is the top-left of the meter block
function drawMeter(bx, by, bw, label, frac, color, valText, valColor, labelSize, valSize) {
var barH = 16;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#9ed6cf";
ctx.font = "700 " + labelSize + "px system-ui, sans-serif";
ctx.fillText(label, bx, by + 20);
var barY = by + 30;
ctx.fillStyle = "rgba(255,255,255,0.10)";
roundRect(bx, barY, bw, barH, 8);
ctx.fill();
ctx.fillStyle = color;
roundRect(bx, barY, Math.max(barH, bw * frac), barH, 8);
ctx.fill();
ctx.fillStyle = valColor;
ctx.font = "700 " + valSize + "px system-ui, sans-serif";
ctx.fillText(valText, bx + 6, barY + barH - 3);
}
// ---- main loop ----
var lastT = 0;
function frame(time) {
var dt = (time - lastT) / 1000;
if (dt > 0.05) dt = 0.05;
if (dt < 0) dt = 0;
lastT = time;
update(dt);
draw(time);
requestAnimationFrame(frame);
}
// ---- input ----
function pointFromEvent(e) {
var rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
if (dragging) return;
var pt = pointFromEvent(e);
var L = layout();
var hp = handlePos(L);
var dx = pt.x - hp.x, dy = pt.y - hp.y;
if (dx * dx + dy * dy <= handleHotR * handleHotR) {
dragging = true;
dragPointerId = e.pointerId;
dragStartY = pt.y;
dragStartLift = lift;
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 L = layout();
var hp = handlePos(L);
// moving the handle down by `travelLen * nPulleys` raises crate fully.
var dyTotal = pt.y - dragStartY;
var fullPull = hp.travelLen * nPulleys;
var deltaLift = dyTotal / fullPull;
lift = clamp(dragStartLift + deltaLift, 0, 1);
});
function endDrag(e) {
if (!dragging || e.pointerId !== dragPointerId) return;
e.preventDefault();
dragging = false;
dragPointerId = null;
}
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
// ---- pulley count buttons ----
var pBtns = [
document.getElementById("p1"),
document.getElementById("p2"),
document.getElementById("p3")
];
function setPulleys(n) {
nPulleys = n;
for (var i = 0; i < pBtns.length; i++) {
if (pBtns[i]) pBtns[i].classList.toggle("on", (i + 1) === n);
}
var msg = n === 1 ? "1 pulley: hard pull, but short rope"
: n === 2 ? "2 pulleys: half the pull, double the rope"
: "3 pulleys: easy pull, but lots of rope!";
hintEl.textContent = msg;
}
if (pBtns[0]) pBtns[0].addEventListener("click", function () { setPulleys(1); });
if (pBtns[1]) pBtns[1].addEventListener("click", function () { setPulleys(2); });
if (pBtns[2]) pBtns[2].addEventListener("click", function () { setPulleys(3); });
document.getElementById("reset").addEventListener("click", function () {
lift = 0;
liftVel = 0;
celebrate = 0;
wonOnce = false;
confetti = [];
hintEl.textContent = "Drag the orange rope handle down to lift the crate";
});
// ---- how-it-works panel ----
var howBtn = document.getElementById("howBtn");
var howPanel = document.getElementById("howPanel");
howBtn.addEventListener("click", function () {
howPanel.classList.toggle("show");
});
// ---- boot ----
window.addEventListener("resize", resize);
resize();
setPulleys(2);
requestAnimationFrame(function (t) {
lastT = t;
requestAnimationFrame(frame);
});
requestAnimationFrame(function () {
stage.classList.add("ready");
});
})();
</script>
</body>
</html>
Game Source: Pulley Lifter
Creator: ArcadeLab
Libraries: none
Complexity: complex (942 lines, 30.9 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: pulley-lifter-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.