$sloprun.dev

Projectile Playground

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A slingshot, a bucket, and the one thing the physics textbook quietly leaves out: every shot draws both the dashed parabola you get by pretending air isn't there and the solid line the ball actually takes once the air pushes back — a curve that leaves the parabola early and comes down steeper than it went up. It opens mid-throw, landing about three metres short of the bucket; lower the angle to 40° and add a little power, because 45° is only the best angle in a vacuum and the readout tells you where the real sweet spot has moved to. Then try the beach ball, which gives up over 90% of its distance to the air and peaks at 24° — the bucket slides in to meet it, because that ball simply cannot throw far — and the steel ball, whose two paths sit almost on top of each other. Paths stay on the field so you can compare them, and the bucket only turns green when you actually land one.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/projectile-playground" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Projectile Playground — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/projectile-playground

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1328 lines 61.3 KB index.html
demos/projectile-playground/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Projectile Playground — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.93); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.93); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.93); --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap { max-width: 1160px; margin: 0 auto; padding: 22px 16px 26px; display: flex; flex-direction: column; gap: 16px; }

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 70ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }

.stage { display: grid; grid-template-columns: minmax(0,1fr) 312px; gap: 16px; align-items: start; }
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.subgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(258px, 1fr)); gap: 12px; align-items: start; }
.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--shadow);
  height: clamp(300px, calc(100vh - 330px), 500px);
  transition: height .22s ease;
}
@media (max-width: 940px) { .field { height: clamp(240px, 46vh, 400px); } }
canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: manipulation; outline: none; }
#sky { cursor: crosshair; }
#sky:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

/* the drag-to-aim grab zone: owns touch so the rest of the page still scrolls */
.grab {
  position: absolute; width: 92px; height: 92px; margin: -46px 0 0 -46px;
  border-radius: 50%; touch-action: none; z-index: 3; cursor: grab;
  background: transparent;
}
.grab:active { cursor: grabbing; }

.hint {
  position: absolute; left: 10px; top: 10px; pointer-events: none; z-index: 4;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
  transition: opacity .5s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }

.legend {
  position: absolute; right: 10px; top: 10px; z-index: 4; pointer-events: none;
  display: flex; flex-direction: column; gap: 5px; align-items: flex-end;
  background: var(--scrim); border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px;
}
.lg { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }
.lg .sw { width: 16px; height: 0; border-top-width: 2px; border-top-style: solid; flex: none; }
.lg.off { display: none; }
@media (max-width: 460px) { .legend { display: none; } }

.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 9px 11px; }
.tbnote { font-size: 12px; color: var(--muted); margin-left: auto; }
.tbnote b { color: var(--ink); font-weight: 600; font-family: var(--font-mono); font-size: 11.5px; }
@media (max-width: 820px) { .tbnote { display: none; } }

.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.card h2 .aux { font-size: 10px; letter-spacing: .06em; text-transform: none; }

/* readout table */
.tbl { display: grid; grid-template-columns: auto minmax(0,1fr) minmax(0,1fr); gap: 6px 10px; align-items: baseline; }
.tbl.solo { grid-template-columns: auto minmax(0,1fr); }
.tbl.solo .air { display: none; }
.tbl .th { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .085em; text-transform: uppercase; color: var(--muted); text-align: right; padding-bottom: 2px; }
.tbl .th.hi { color: var(--accent); }
.tbl .rk { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .085em; text-transform: uppercase; color: var(--muted); }
.tbl .rv { font-family: var(--font-mono); font-size: 14px; font-variant-numeric: tabular-nums; text-align: right; color: var(--muted); white-space: nowrap; }
.tbl .rv.hi { color: var(--accent); font-size: 15px; }
.tbl .rv .u { font-size: 10px; margin-left: 2px; opacity: .8; }

.pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 12px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
  padding: 4px 10px 4px 8px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--bg); max-width: 100%;
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.busy { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.busy .dot { background: var(--accent); animation: blip 1s steps(2, end) infinite; }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; font-weight: 600; }
.pill.ok .dot { background: var(--run); }
@keyframes blip { 0%,100% { opacity: 1; } 50% { opacity: .3; } }

.fline { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 9px; }
.fline .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .085em; text-transform: uppercase; color: var(--muted); }
.fline .val { font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
.fline .val.hi { color: var(--accent); }
.fline .val .dim { color: var(--muted); }
.fline .val.win { color: var(--run); font-weight: 600; }

.cap { margin: 10px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap code { font-family: var(--font-mono); font-size: .89em; color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 1px 4px; }

/* controls */
.ctl + .ctl { margin-top: 13px; }
.ctl .row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.ctl .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .085em; text-transform: uppercase; color: var(--muted); }
.ctl .val { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); font-variant-numeric: tabular-nums; }
input[type="range"] {
  -webkit-appearance: none; appearance: none; width: 100%; height: 20px;
  background: transparent; accent-color: var(--accent); cursor: pointer; margin: 0; display: block;
}
input[type="range"]:focus { outline: none; }
input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  box-shadow: 0 0 0 1px var(--accent);
}
input[type="range"]::-moz-range-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type="range"]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent);
}

/* buttons */
button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .38; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.primary span.m { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; opacity: .85; }

.balls { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; margin-top: 6px; }
.balls button { display: flex; align-items: center; gap: 7px; justify-content: flex-start; font-size: 12.5px; padding: 7px 8px; text-align: left; }
.balls button .bd { border-radius: 50%; background: var(--muted); opacity: .4; flex: none; }
.balls button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600; }
.balls button[aria-pressed="true"] .bd { background: var(--accent); opacity: 1; }

.toggles { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; margin-top: 13px; }
.toggles button { display: inline-flex; align-items: center; gap: 6px; justify-content: center; font-size: 12px; padding: 7px 6px; }
.toggles button .sw { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; opacity: .45; }
.toggles button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600; }
.toggles button[aria-pressed="true"] .sw { background: var(--accent); opacity: 1; }

/* shot log */
.shots { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.shots li { min-width: 0; }
.shots button {
  width: 100%; display: grid; grid-template-columns: 30px minmax(0,1fr) auto; gap: 8px;
  align-items: center; padding: 6px 8px; font-family: var(--font-mono); font-size: 11.5px;
  font-variant-numeric: tabular-nums; background: var(--bg);
}
.shots button .n { color: var(--muted); font-size: 10.5px; }
.shots button .d { color: var(--muted); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.shots button .r { white-space: nowrap; }
.shots button.sel { border-color: var(--accent); background: var(--accent-soft); }
.shots button.sel .n, .shots button.sel .d { color: var(--accent); }
.shots button.hit .r { color: var(--run); font-weight: 600; }
.shots button:hover .d { color: inherit; }
.empty { font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin: 0; }

.prose { margin: 0; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.prose + .prose { margin-top: 9px; }
.prose b { color: var(--ink); font-weight: 600; }
.prose code { font-family: var(--font-mono); font-size: .9em; color: var(--accent); }

footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }

.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* one column: rebuild the reading order so the numbers follow the field */
@media (max-width: 940px) {
  .stage { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
  .left, .panel, .subgrid { display: contents; }
  .field { order: 1; } .toolbar { order: 2; }
  #cReadout { order: 3; } #cAim { order: 4; } #cBall { order: 5; } #cPaths { order: 6; } #cWhy { order: 7; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Projectile Playground</h1>
    <p class="lede">
      Pull the slingshot back, let go, and try to drop the ball in the bucket. The dashed line is the
      neat parabola from the textbook — the one that ignores air. The solid line is what actually
      happens once the air pushes back. <b>The gap between them is the whole story.</b>
    </p>
  </header>

  <main class="stage">

    <div class="left">
      <div class="field" id="field">
        <canvas id="sky" tabindex="0" aria-label="Launch field. A slingshot on the left, a bucket on the ground to the right, and the flight paths of your shots. Left and right arrows change the angle, up and down change the power, Enter launches.">
          A slingshot launching balls at a bucket, with flight paths drawn. Your browser does not support canvas.
        </canvas>
        <div class="grab" id="grab" aria-hidden="true"></div>
        <div class="hint" id="hint">drag the ball back to aim &mdash; or use the sliders</div>
        <div class="legend" id="legend">
          <span class="lg" id="lgAir"><span class="sw" style="border-top-color:var(--accent)"></span>with air</span>
          <span class="lg" id="lgIdeal"><span class="sw" style="border-top-style:dashed;border-top-color:var(--muted)"></span>no air</span>
        </div>
      </div>

      <div class="card toolbar" role="group" aria-label="Launch controls">
        <button type="button" class="primary" id="fireBtn">Launch <span class="m" id="fireLbl">45° · 24 m/s</span></button>
        <button type="button" id="moveBtn">Move the bucket</button>
        <button type="button" id="clearBtn">Clear paths</button>
        <span class="tbnote">Click the field, then <b>&larr; &rarr;</b> angle, <b>&uarr; &darr;</b> power, <b>Enter</b> to launch.</span>
      </div>

      <div class="subgrid">
      <section class="card" id="cPaths" aria-label="Shots so far">
        <h2>Paths kept <span class="aux" id="shotAux"></span></h2>
        <ul class="shots" id="shots"></ul>
        <p class="empty" id="shotsEmpty" hidden>no shots yet</p>
        <p class="cap">Tap a path to load its settings back into the sliders.</p>
      </section>

      <section class="card" id="cWhy" aria-label="What is going on">
        <h2>What&rsquo;s going on</h2>
        <p class="prose">
          Gravity pulls down by the same amount no matter how fast you are going. Air doesn&rsquo;t: its push
          grows with <b>speed squared</b>, so it hits hardest right after release and eases off as the ball
          slows. That is why the solid path leaves the dashed one early, then falls steeper than it rose —
          the shape stops being a parabola at all.
        </p>
        <p class="prose">
          It also moves the sweet spot. In still vacuum the farthest throw is at <code>45°</code>. Add air and
          the best angle drops — around <code>40°</code> for a tennis ball, and near <code>24°</code> for a beach
          ball, which spends so long fighting the air that a high, slow arc is simply wasted time.
        </p>
        <p class="prose">
          Try the <b>steel ball</b>: heavy, small, and so it barely notices the air. Its two paths sit almost on
          top of each other, which is exactly why the textbook version was ever a decent guess.
        </p>
      </section>
      </div>
    </div>

    <div class="panel">

      <section class="card" id="cReadout" aria-label="Flight readout">
        <h2>This shot <span class="aux" id="madeAux"></span></h2>
        <div class="tbl" id="tbl">
          <div class="th"></div><div class="th" id="thL">no air</div><div class="th hi air" id="thR">with air</div>
          <div class="rk">Distance</div><div class="rv" id="dL">&mdash;</div><div class="rv hi air" id="dR">&mdash;</div>
          <div class="rk">Peak height</div><div class="rv" id="pL">&mdash;</div><div class="rv hi air" id="pR">&mdash;</div>
          <div class="rk">Time in air</div><div class="rv" id="tL">&mdash;</div><div class="rv hi air" id="tR">&mdash;</div>
          <div class="rk">Landing speed</div><div class="rv" id="vL">&mdash;</div><div class="rv hi air" id="vR">&mdash;</div>
        </div>
        <span class="pill" id="pill" role="status"><span class="dot"></span><span id="pillText">loaded &mdash; ready to launch</span></span>
        <div class="fline"><span class="lbl">Bucket sits at</span><span class="val" id="fBucket">&mdash;</span></div>
        <div class="fline"><span class="lbl">Best angle now</span><span class="val hi" id="fBest">&mdash;</span></div>
        <p class="cap" id="fCost">Air is taking a bite out of this shot.</p>
      </section>

      <section class="card" id="cAim" aria-label="Aim">
        <h2>Aim</h2>

        <div class="ctl">
          <div class="row"><label class="lbl" for="angle">Angle</label><span class="val" id="vAngle">45°</span></div>
          <input type="range" id="angle" min="5" max="85" step="1" value="45">
        </div>

        <div class="ctl">
          <div class="row"><label class="lbl" for="power">Power</label><span class="val" id="vPower">24.0 m/s</span></div>
          <input type="range" id="power" min="8" max="34" step="0.5" value="24">
        </div>

        <div class="ctl">
          <div class="row"><label class="lbl" for="dist">Bucket at</label><span class="val" id="vDist">38 m</span></div>
          <input type="range" id="dist" min="4" max="95" step="1" value="38">
        </div>

        <div class="toggles" role="group" aria-label="Options">
          <button type="button" id="airBtn" aria-pressed="true"><span class="sw" aria-hidden="true"></span>Air is on</button>
          <button type="button" id="ghostBtn" aria-pressed="true"><span class="sw" aria-hidden="true"></span>Keep paths</button>
        </div>
      </section>

      <section class="card" id="cBall" aria-label="Ball">
        <h2>Ball</h2>
        <div class="balls" id="balls" role="group" aria-label="Pick a ball"></div>
        <div class="fline"><span class="lbl">Weight &amp; size</span><span class="val" id="fBall">&mdash;</span></div>
        <div class="fline"><span class="lbl">Drag factor</span><span class="val hi" id="fK">&mdash;</span></div>
        <p class="cap">Drag factor is <code>½·ρ·C·A ÷ m</code> — how much air the ball has to shove aside for every gram it carries. A beach ball is nearly all sail and no mass.</p>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>
<p class="sr-only" id="live" aria-live="polite"></p>

<script>
(function () {
  "use strict";

  /* ── theme + motion plumbing ────────────────────────────────────────────── */
  var root = document.documentElement;
  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
  function reduced() { return !!(mqRM && mqRM.matches); }

  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      readColors(); requestFrame();
    }
  });
  if (mqDark) {
    var onScheme = function () { readColors(); requestFrame(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }

  /* ── elements ───────────────────────────────────────────────────────────── */
  var $ = function (id) { return document.getElementById(id); };
  var field = $("field"), cv = $("sky");
  var ctx = null;
  try { ctx = cv.getContext("2d"); } catch (err) { ctx = null; }
  if (!ctx) {
    /* no 2d canvas (blocked, or a very old browser): say so instead of throwing */
    field.style.height = "auto";
    field.innerHTML = '<p style="margin:0;padding:20px;font-family:var(--font-mono);' +
      'font-size:12px;line-height:1.6;color:var(--danger)" role="alert">Canvas is not available in ' +
      'this browser, so the flight paths can&rsquo;t be drawn. Every part of this demo needs it.</p>';
    var dead = document.querySelectorAll(".wrap button, .wrap input"), di;
    for (di = 0; di < dead.length; di++) dead[di].disabled = true;
    return;
  }
  var grab = $("grab"), hint = $("hint"), live = $("live");
  var thL = $("thL"), thR = $("thR"), tbl = $("tbl");
  var dL = $("dL"), dR = $("dR"), pL = $("pL"), pR = $("pR");
  var tL = $("tL"), tR = $("tR"), vL = $("vL"), vR = $("vR");
  var pill = $("pill"), pillText = $("pillText");
  var fBucket = $("fBucket"), fBest = $("fBest"), fCost = $("fCost");
  var fBall = $("fBall"), fK = $("fK"), madeAux = $("madeAux"), shotAux = $("shotAux");
  var angleIn = $("angle"), powerIn = $("power"), distIn = $("dist");
  var vAngle = $("vAngle"), vPower = $("vPower"), vDist = $("vDist");
  var airBtn = $("airBtn"), ghostBtn = $("ghostBtn"), ballsBox = $("balls");
  var fireBtn = $("fireBtn"), fireLbl = $("fireLbl"), moveBtn = $("moveBtn"), clearBtn = $("clearBtn");
  var shotsUl = $("shots"), shotsEmpty = $("shotsEmpty"), lgIdeal = $("lgIdeal"), lgAir = $("lgAir");

  var MONO = 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace';

  /* ── physics constants ──────────────────────────────────────────────────── */
  var G = 9.81;          /* m/s²                                             */
  var H0 = 1.2;          /* release height of the pouch, metres              */
  var HALF = 1.25;       /* half-width of the bucket mouth, metres           */
  var DT = 0.004;        /* integrator step, seconds                         */

  var BALLS = [
    { name: "Beach ball",  k: 0.3630,  m: 100,  d: 40,   r: 9 },
    { name: "Tennis ball", k: 0.01806, m: 58,   d: 6.6,  r: 5 },
    { name: "Baseball",    k: 0.00622, m: 145,  d: 7.3,  r: 5.5 },
    { name: "Steel ball",  k: 0.00082, m: 1000, d: 6.0,  r: 4.5 }
  ];

  /* ── colours read from the tokens ───────────────────────────────────────── */
  var COL = { accent: "#C05600", ink: "#1A1D21", line: "#E3E2DC", muted: "#5A6068", surface: "#FFFFFF", run: "#1A7F37" };
  var inkRGB = [26, 29, 33], accRGB = [192, 86, 0], runRGB = [26, 127, 55], mutRGB = [90, 96, 104];

  function cssVar(n, fb) {
    var v = getComputedStyle(root).getPropertyValue(n);
    v = v ? v.trim() : "";
    return v || fb;
  }
  function hexToRgb(h) {
    h = String(h).trim(); if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    var n = parseInt(h, 16);
    if (!isFinite(n)) return [26, 29, 33];
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function readColors() {
    COL.accent  = cssVar("--accent", "#C05600");
    COL.ink     = cssVar("--ink", "#1A1D21");
    COL.line    = cssVar("--line", "#E3E2DC");
    COL.muted   = cssVar("--muted", "#5A6068");
    COL.surface = cssVar("--surface", "#FFFFFF");
    COL.run     = cssVar("--run", "#1A7F37");
    inkRGB = hexToRgb(COL.ink); accRGB = hexToRgb(COL.accent); runRGB = hexToRgb(COL.run);
    mutRGB = hexToRgb(COL.muted);
  }
  function ink(a) { return "rgba(" + inkRGB[0] + "," + inkRGB[1] + "," + inkRGB[2] + "," + a + ")"; }
  function acc(a) { return "rgba(" + accRGB[0] + "," + accRGB[1] + "," + accRGB[2] + "," + a + ")"; }
  function grn(a) { return "rgba(" + runRGB[0] + "," + runRGB[1] + "," + runRGB[2] + "," + a + ")"; }
  function mut(a) { return "rgba(" + mutRGB[0] + "," + mutRGB[1] + "," + mutRGB[2] + "," + a + ")"; }

  /* ── helpers ────────────────────────────────────────────────────────────── */
  function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
  function fx(n, d) { return (Math.round(n * Math.pow(10, d)) / Math.pow(10, d)).toFixed(d); }
  function setTxt(el, t) { if (el && el.textContent !== t) el.textContent = t; }
  function say(t) { if (live.textContent !== t) live.textContent = t; }

  /* ── state ──────────────────────────────────────────────────────────────── */
  var S = {
    angle: 45, power: 24, dist: 38, ball: 1,
    air: true, keep: true,
    made: 0, fired: 0, sel: -1
  };
  var trails = [];         /* kept paths                                     */
  var flight = null;       /* the ball currently in the air                  */
  var sparks = [];         /* celebration bits                               */
  var lastResult = null;   /* the shot that just landed                      */
  var bucketHot = false;   /* the bucket stays green until the next launch   */
  var bucketGlow = 0;      /* 1 → 0 flare on top of that                     */

  /* ── the maths ──────────────────────────────────────────────────────────── */

  /* No air: closed form, launched from H0. */
  function idealShot(v, angDeg) {
    var th = angDeg * Math.PI / 180, vx = v * Math.cos(th), vy = v * Math.sin(th);
    var t = (vy + Math.sqrt(vy * vy + 2 * G * H0)) / G;
    var n = 140, xs = new Float64Array(n + 1), ys = new Float64Array(n + 1), ts = new Float64Array(n + 1), i, tt;
    for (i = 0; i <= n; i++) {
      tt = t * i / n;
      xs[i] = vx * tt; ys[i] = Math.max(0, H0 + vy * tt - 0.5 * G * tt * tt); ts[i] = tt;
    }
    return {
      xs: xs, ys: ys, ts: ts, n: n + 1,
      range: vx * t, peak: H0 + vy * vy / (2 * G), time: t,
      vimp: Math.sqrt(vx * vx + (vy - G * t) * (vy - G * t))
    };
  }

  /* With air: quadratic drag, a = -k|v|v, integrated with RK4. */
  function deriv(s, k, out) {
    var sp = Math.sqrt(s[2] * s[2] + s[3] * s[3]);
    out[0] = s[2]; out[1] = s[3];
    out[2] = -k * sp * s[2];
    out[3] = -G - k * sp * s[3];
  }
  var _a = [0,0,0,0], _b = [0,0,0,0], _c = [0,0,0,0], _e = [0,0,0,0], _t = [0,0,0,0];
  function stepRK4(s, k, dt) {
    var j;
    deriv(s, k, _a);
    for (j = 0; j < 4; j++) _t[j] = s[j] + dt / 2 * _a[j];
    deriv(_t, k, _b);
    for (j = 0; j < 4; j++) _t[j] = s[j] + dt / 2 * _b[j];
    deriv(_t, k, _c);
    for (j = 0; j < 4; j++) _t[j] = s[j] + dt * _c[j];
    deriv(_t, k, _e);
    for (j = 0; j < 4; j++) s[j] += dt / 6 * (_a[j] + 2 * _b[j] + 2 * _c[j] + _e[j]);
  }

  function dragShot(v, angDeg, k, wantPath) {
    var th = angDeg * Math.PI / 180;
    var s = [0, H0, v * Math.cos(th), v * Math.sin(th)];
    var px = 0, py = H0, pvx = s[2], pvy = s[3];
    var t = 0, peak = H0, i, f, MAXI = 12000;
    var xs = [], ys = [], ts = [], keepEvery = 2;
    if (wantPath) { xs.push(0); ys.push(H0); ts.push(0); }
    for (i = 1; i <= MAXI; i++) {
      px = s[0]; py = s[1]; pvx = s[2]; pvy = s[3];
      stepRK4(s, k, DT);
      t += DT;
      if (s[1] > peak) peak = s[1];
      if (s[1] <= 0) {
        f = (py - s[1]) !== 0 ? py / (py - s[1]) : 0;
        var lx = px + f * (s[0] - px), lt = t - DT + f * DT;
        if (wantPath) { xs.push(lx); ys.push(0); ts.push(lt); }
        return {
          xs: xs, ys: ys, ts: ts, n: xs.length,
          range: lx, peak: peak, time: lt,
          vimp: Math.sqrt(pvx * pvx + pvy * pvy)
        };
      }
      if (wantPath && i % keepEvery === 0) { xs.push(s[0]); ys.push(s[1]); ts.push(t); }
    }
    return { xs: xs, ys: ys, ts: ts, n: xs.length, range: s[0], peak: peak, time: t,
             vimp: Math.sqrt(s[2] * s[2] + s[3] * s[3]) };
  }

  function dragRange(v, angDeg, k) { return dragShot(v, angDeg, k, false).range; }

  /* Angle that throws farthest, given the current ball. */
  function bestAngleAir(v, k) {
    var best = 45, br = -1, a, r;
    for (a = 6; a <= 84; a += 2) { r = dragRange(v, a, k); if (r > br) { br = r; best = a; } }
    for (a = best - 2; a <= best + 2; a += 0.5) {
      if (a < 5 || a > 85) continue;
      r = dragRange(v, a, k); if (r > br) { br = r; best = a; }
    }
    return { angle: best, range: br };
  }
  /* Farthest this ball can go at all — used to keep the bucket reachable.
     A beach ball tops out near 7 m, so parking the bucket at 38 m would make
     the whole game unwinnable the moment you picked it. */
  function maxReach(bi) {
    var b = BALLS[bi];
    if (b._reach === undefined) b._reach = bestAngleAir(+powerIn.max, b.k).range;
    return b._reach;
  }
  function bestAngleIdeal(v) {
    /* launching from height H0 the optimum dips just under 45° */
    var s = 1 / Math.sqrt(2 + 2 * G * H0 / (v * v));
    return Math.asin(clamp(s, 0, 1)) * 180 / Math.PI;
  }

  /* ── derived, cached per settings change ────────────────────────────────── */
  var cur = { ideal: null, air: null, best: null, bestKey: "" };

  function recompute() {
    var k = BALLS[S.ball].k;
    cur.ideal = idealShot(S.power, S.angle);
    cur.air = S.air ? dragShot(S.power, S.angle, k, true) : null;
    var key = S.power + "|" + S.ball + "|" + (S.air ? 1 : 0);
    if (key !== cur.bestKey) {
      cur.bestKey = key;
      cur.best = S.air ? bestAngleAir(S.power, k) : { angle: bestAngleIdeal(S.power), range: idealShot(S.power, bestAngleIdeal(S.power)).range };
    }
  }

  function realShot() { return S.air ? cur.air : cur.ideal; }

  /* ── canvas sizing ──────────────────────────────────────────────────────── */
  var CW = 0, CH = 0, DPR = 1, measured = false;
  function resize() {
    var r = field.getBoundingClientRect();
    var w = Math.round(r.width), h = Math.round(r.height);
    /* A hidden or not-yet-flowed box measures ~0. Taking that as real would latch
       a tight layout and a 4× wrong scale for the rest of the session, and nothing
       recomputes the fitted height afterwards — so wait for a real measurement. */
    if (w < 60 || h < 60) { measured = false; return; }
    if (!measured) scale = 0;         /* first real size: snap, don't ease from junk */
    measured = true;
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    CW = w;
    CH = h;
    var tight = CW < 640;
    PADL = tight ? 30 : 40; PADR = tight ? 14 : 20;
    PADT = tight ? 16 : 24; PADB = tight ? 36 : 42;
    var pw = Math.round(CW * DPR), ph = Math.round(CH * DPR);
    /* assigning width/height wipes the bitmap, so only do it when it really moved */
    if (cv.width !== pw || cv.height !== ph) { cv.width = pw; cv.height = ph; }
    requestFrame();
  }

  /* ── world → pixel mapping (uniform scale, so the arcs stay honest) ─────── */
  var PADL = 40, PADR = 20, PADT = 24, PADB = 42;
  var scale = 0, scaleTarget = 0, originX = 0, originY = 0, viewTop = 20;

  function computeScale() {
    var maxX = Math.max(S.dist + 4, 16), maxY = 5, i;
    var a = cur.ideal, b = cur.air;
    if (a) { maxX = Math.max(maxX, a.range); maxY = Math.max(maxY, a.peak); }
    if (b) { maxX = Math.max(maxX, b.range); maxY = Math.max(maxY, b.peak); }
    if (flight) { maxX = Math.max(maxX, flight.path.range); maxY = Math.max(maxY, flight.path.peak); }
    for (i = 0; i < trails.length; i++) { maxX = Math.max(maxX, trails[i].range); maxY = Math.max(maxY, trails[i].peak); }
    /* quantise so tiny slider moves don't jiggle the whole view */
    var wW = Math.ceil(maxX * 1.07 / 5) * 5;
    var wH = Math.ceil(maxY * 1.20 / 2) * 2;
    var availW = Math.max(40, CW - PADL - PADR), availH = Math.max(40, CH - PADT - PADB);
    scaleTarget = Math.min(availW / wW, availH / wH);
    viewTop = wH;   /* stop the height ruler where the flight stops — the rest reads as sky */
    lastWW = wW; lastWH = wH;
    if (scale === 0) scale = scaleTarget;
    originX = PADL;
    originY = CH - PADB;
  }

  /* A throw is a wide, flat thing. Let the field box take the shape the flight
     actually needs instead of leaving two thirds of it as empty sky. Only ever
     called when nothing is mid-gesture, so controls never move under a finger. */
  var lastWW = 0, lastWH = 0;
  function fitHeight() {
    if (!measured || flight || aiming) return;
    computeScale();
    var availW = Math.max(40, CW - PADL - PADR);
    var want = lastWH * (availW / lastWW) + PADT + PADB;
    var narrow = CW < 640;
    var minH = narrow ? 205 : 285;
    var maxH = Math.max(minH, Math.min(narrow ? 400 : 500, (window.innerHeight || 800) - 260));
    want = Math.round(clamp(want, minH, maxH) / 8) * 8;
    if (Math.abs(want - CH) <= 16) return;
    field.style.height = want + "px";
    if (!window.ResizeObserver) setTimeout(resize, 260);
  }
  function px(x) { return originX + x * scale; }
  function py(y) { return originY - y * scale; }

  /* ── drawing ────────────────────────────────────────────────────────────── */
  function tickStep(spanM) {
    var cands = [1, 2, 5, 10, 20, 25, 50, 100], i;
    for (i = 0; i < cands.length; i++) if (spanM / cands[i] <= 9) return cands[i];
    return 200;
  }

  function drawPath(p, from, to) {
    var i, started = false;
    ctx.beginPath();
    for (i = from; i <= to && i < p.n; i++) {
      if (!started) { ctx.moveTo(px(p.xs[i]), py(p.ys[i])); started = true; }
      else ctx.lineTo(px(p.xs[i]), py(p.ys[i]));
    }
    ctx.stroke();
  }

  /* The slingshot: frame at x = 0, ball resting at the release height. While the
     visitor drags, the ball follows the pointer and the bands stretch to it. */
  function drawSling(aimAng, pullFrac, pouchX, pouchY) {
    var bx = px(0), gy = py(0), by = py(H0);
    var topY = Math.min(by - 7, gy - 34);          /* prong tips, always legible */
    var forkY = topY + (gy - topY) * 0.46;
    /* at rest the pouch sits drawn back along the aim, further for more power */
    var th = aimAng * Math.PI / 180, pull = 7 + pullFrac * 25;
    var ox = pouchX === null ? bx - Math.cos(th) * pull : pouchX;
    var oy = pouchY === null ? Math.min(gy - 4, by + Math.sin(th) * pull) : pouchY;

    ctx.lineCap = "round"; ctx.lineJoin = "round";
    ctx.strokeStyle = ink(0.5); ctx.lineWidth = 2.8;
    ctx.beginPath(); ctx.moveTo(bx, gy); ctx.lineTo(bx, forkY); ctx.stroke();
    ctx.lineWidth = 2.2;
    ctx.beginPath();
    ctx.moveTo(bx, forkY); ctx.lineTo(bx - 7, topY);
    ctx.moveTo(bx, forkY); ctx.lineTo(bx + 7, topY);
    ctx.stroke();

    /* bands */
    ctx.strokeStyle = acc(0.7); ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.moveTo(bx - 7, topY); ctx.lineTo(ox, oy); ctx.lineTo(bx + 7, topY);
    ctx.stroke();

    /* the angle, drawn as an angle */
    ctx.strokeStyle = ink(0.2); ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + 20, by); ctx.stroke();
    ctx.beginPath(); ctx.arc(bx, by, 16, -th, 0); ctx.stroke();

    return { x: ox, y: oy, bx: bx, by: by };
  }

  function drawBall(x, y, r, fill) {
    ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = fill; ctx.fill();
    ctx.lineWidth = 1; ctx.strokeStyle = COL.surface; ctx.stroke();
  }

  function drawBucket() {
    var cx = px(S.dist), gy = py(0);
    var w = Math.max(9, HALF * scale);          /* half-width in px          */
    var h = Math.min(26, Math.max(12, w * 0.9));
    var hot = bucketHot;
    var stroke = hot ? grn(0.9) : ink(0.5);
    var fill = hot ? grn(0.18 + 0.3 * bucketGlow) : ink(0.05);

    /* guide line so a far bucket is still findable */
    ctx.save();
    ctx.setLineDash([2, 5]); ctx.lineWidth = 1;
    ctx.strokeStyle = hot ? grn(0.45) : ink(0.16);
    ctx.beginPath(); ctx.moveTo(cx, gy - h); ctx.lineTo(cx, gy - h - 34); ctx.stroke();
    ctx.restore();

    /* the bucket: a tapered tub sitting on the ground */
    ctx.beginPath();
    ctx.moveTo(cx - w, gy - h);
    ctx.lineTo(cx - w * 0.66, gy);
    ctx.lineTo(cx + w * 0.66, gy);
    ctx.lineTo(cx + w, gy - h);
    ctx.closePath();
    ctx.fillStyle = fill; ctx.fill();
    ctx.lineWidth = 1.8; ctx.strokeStyle = stroke; ctx.stroke();
    /* rim */
    ctx.beginPath(); ctx.moveTo(cx - w - 1.5, gy - h); ctx.lineTo(cx + w + 1.5, gy - h);
    ctx.lineWidth = 2.4; ctx.strokeStyle = stroke; ctx.stroke();

    /* label */
    ctx.font = "600 10px " + MONO;
    ctx.textAlign = "center"; ctx.textBaseline = "bottom";
    ctx.fillStyle = hot ? grn(1) : mut(1);
    ctx.fillText(hot ? "IN!" : S.dist + " m", cx, gy - h - 38);
  }

  function drawGround() {
    var gy = py(0);
    ctx.fillStyle = ink(0.035);
    ctx.fillRect(0, gy, CW, CH - gy);
    ctx.strokeStyle = ink(0.42); ctx.lineWidth = 1.4;
    ctx.beginPath(); ctx.moveTo(0, gy + 0.5); ctx.lineTo(CW, gy + 0.5); ctx.stroke();

    var spanX = (CW - PADL - PADR) / scale, step = tickStep(spanX), i, x;
    ctx.font = "9.5px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "top";
    for (i = step; i * scale + PADL < CW - 4; i += step) {
      x = px(i);
      ctx.strokeStyle = ink(0.2); ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(x, gy); ctx.lineTo(x, gy + 5); ctx.stroke();
      ctx.fillStyle = mut(0.95);
      ctx.fillText(String(i), x, gy + 6);
    }
    ctx.textAlign = "right"; ctx.fillStyle = mut(0.8);
    ctx.fillText("m", PADL - 9, gy + 6);

    /* height ruler */
    var spanY = Math.min((CH - PADT - PADB) / scale, viewTop), stepY = tickStep(spanY), j, y;
    ctx.textAlign = "right"; ctx.textBaseline = "middle";
    for (j = stepY; j <= spanY + 0.01; j += stepY) {
      y = py(j);
      ctx.strokeStyle = ink(0.09); ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(PADL - 6, y + 0.5); ctx.lineTo(CW - 2, y + 0.5); ctx.stroke();
      ctx.fillStyle = mut(0.9);
      ctx.fillText(String(j), PADL - 9, y);
    }
  }

  function drawApex(p, colour) {
    var i, bi = 0, best = -1;
    for (i = 0; i < p.n; i++) if (p.ys[i] > best) { best = p.ys[i]; bi = i; }
    var x = px(p.xs[bi]), y = py(p.ys[bi]);
    ctx.save();
    ctx.setLineDash([2, 4]); ctx.lineWidth = 1; ctx.strokeStyle = colour(0.3);
    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, py(0)); ctx.stroke();
    ctx.restore();
    ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fillStyle = colour(0.75); ctx.fill();
    ctx.font = "9.5px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "bottom";
    ctx.fillStyle = colour(0.85);
    ctx.fillText(fx(best, 1) + " m", x, y - 6);
  }

  function drawLandMark(p, colour, label, dash) {
    var x = px(p.range), gy = py(0);
    ctx.save();
    if (dash) ctx.setLineDash([2, 3]);
    ctx.strokeStyle = colour(0.55); ctx.lineWidth = 1.4;
    ctx.beginPath(); ctx.moveTo(x, gy - 6); ctx.lineTo(x, gy + 6); ctx.stroke();
    ctx.restore();
    if (label) {
      ctx.font = "9.5px " + MONO; ctx.textAlign = "center"; ctx.textBaseline = "top";
      ctx.fillStyle = colour(0.8);
      ctx.fillText(fx(p.range, 1) + " m", x, gy + 19);
    }
  }

  function draw() {
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
    ctx.clearRect(0, 0, CW, CH);
    ctx.fillStyle = COL.surface; ctx.fillRect(0, 0, CW, CH);
    ctx.lineCap = "round"; ctx.lineJoin = "round";

    drawGround();
    drawBucket();

    /* kept paths from earlier shots */
    var i, tr;
    for (i = 0; i < trails.length; i++) {
      tr = trails[i];
      var isSel = (S.sel === tr.id);
      ctx.strokeStyle = isSel ? acc(0.6) : (tr.hit ? grn(0.55) : mut(0.5));
      ctx.lineWidth = isSel ? 2 : 1.2;
      drawPath(tr.path, 0, tr.path.n - 1);
      ctx.beginPath(); ctx.arc(px(tr.range), py(0), isSel ? 3.2 : 2.2, 0, Math.PI * 2);
      ctx.fillStyle = isSel ? acc(0.8) : (tr.hit ? grn(0.8) : mut(0.7)); ctx.fill();
    }

    var real = realShot();

    /* the no-air ghost */
    if (S.air && cur.ideal) {
      ctx.save();
      ctx.setLineDash([5, 5]);
      ctx.strokeStyle = mut(0.9); ctx.lineWidth = 1.5;
      drawPath(cur.ideal, 0, cur.ideal.n - 1);
      ctx.restore();
      var apart = Math.abs(cur.ideal.range - real.range) * scale;
      drawLandMark(cur.ideal, mut, apart > 40, true);
    }

    /* the path the ball will actually take */
    if (real) {
      ctx.strokeStyle = acc(0.95); ctx.lineWidth = 2.2;
      drawPath(real, 0, real.n - 1);
      drawApex(real, acc);
      drawLandMark(real, acc, true, false);
    }

    /* live ball */
    if (flight) {
      var pt = sampleAt(flight.path, flight.t);
      drawBall(px(pt.x), py(pt.y), BALLS[flight.ball].r, COL.accent);
    }

    /* celebration bits */
    for (i = 0; i < sparks.length; i++) {
      var sp = sparks[i];
      ctx.globalAlpha = clamp(sp.life, 0, 1);
      ctx.beginPath(); ctx.arc(px(sp.x), py(sp.y), sp.r, 0, Math.PI * 2);
      ctx.fillStyle = COL.run; ctx.fill();
      ctx.globalAlpha = 1;
    }

    /* slingshot last, so it sits on top */
    var pouch = drawSling(S.angle, (S.power - 8) / 26,
                          aiming && aimMoved ? aimPx : null, aiming && aimMoved ? aimPy : null);
    if (!flight) {
      if (!hintGone) {   /* a grab-me ring until the visitor has touched anything */
        ctx.save();
        ctx.setLineDash([3, 4]); ctx.lineWidth = 1.2; ctx.strokeStyle = acc(0.5);
        ctx.beginPath(); ctx.arc(pouch.x, pouch.y, BALLS[S.ball].r + 7, 0, Math.PI * 2); ctx.stroke();
        ctx.restore();
      }
      drawBall(pouch.x, pouch.y, BALLS[S.ball].r, COL.accent);
    }

    if (aiming && aimMoved) {
      ctx.font = "600 11px " + MONO; ctx.textAlign = "left"; ctx.textBaseline = "middle";
      ctx.fillStyle = COL.accent;
      ctx.fillText(S.angle + "° · " + fx(S.power, 1) + " m/s", pouch.x - 4, pouch.y - 22);
    }

    positionGrab();
  }

  function sampleAt(p, t) {
    var lo = 0, hi = p.n - 1, mid;
    if (t <= 0) return { x: p.xs[0], y: p.ys[0] };
    if (t >= p.ts[hi]) return { x: p.xs[hi], y: p.ys[hi] };
    while (hi - lo > 1) { mid = (lo + hi) >> 1; if (p.ts[mid] <= t) lo = mid; else hi = mid; }
    var span = p.ts[hi] - p.ts[lo], f = span > 0 ? (t - p.ts[lo]) / span : 0;
    return { x: p.xs[lo] + f * (p.xs[hi] - p.xs[lo]), y: p.ys[lo] + f * (p.ys[hi] - p.ys[lo]) };
  }

  /* ── animation loop ─────────────────────────────────────────────────────── */
  var raf = 0, lastTs = 0;
  function requestFrame() { if (ctx && !raf) raf = requestAnimationFrame(frame); }
  function busy() {
    return !!flight || sparks.length > 0 || bucketGlow > 0.01 ||
           Math.abs(scale - scaleTarget) > scaleTarget * 0.0025;
  }
  function frame(ts) {
    raf = 0;
    var dt = lastTs ? Math.min(0.05, (ts - lastTs) / 1000) : 0.016;
    lastTs = ts;

    computeScale();
    if (reduced()) scale = scaleTarget;
    else scale += (scaleTarget - scale) * Math.min(1, dt * 9);

    if (flight) {
      flight.t += dt;
      if (flight.t >= flight.path.time) { flight.t = flight.path.time; land(); }
    }
    var i;
    for (i = sparks.length - 1; i >= 0; i--) {
      var s = sparks[i];
      s.vy -= G * dt; s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt * 1.25;
      if (s.life <= 0 || s.y < 0) sparks.splice(i, 1);
    }
    if (bucketGlow > 0) bucketGlow = Math.max(0, bucketGlow - dt * 0.9);

    draw();
    if (busy()) requestFrame();
  }

  /* ── launching ──────────────────────────────────────────────────────────── */
  function launch(auto) {
    if (flight) return;
    recompute();
    var path = realShot();
    if (!path) return;
    S.fired++;
    var rec = {
      id: S.fired, angle: S.angle, power: S.power, ball: S.ball, air: S.air,
      path: path, range: path.range, peak: path.peak, time: path.time, vimp: path.vimp, hit: false
    };
    var off = path.range - S.dist;
    rec.hit = Math.abs(off) <= HALF;
    rec.off = off;

    flight = { path: path, t: 0, ball: S.ball, rec: rec };
    bucketHot = false; bucketGlow = 0;
    setPill("busy", "in the air…");
    fireBtn.disabled = true;
    if (!auto) hideHint();
    if (reduced()) { flight.t = path.time; land(); }
    requestFrame();
  }

  function land() {
    var rec = flight.rec;
    flight = null;
    lastResult = rec;
    fireBtn.disabled = false;

    if (rec.hit) {
      S.made++;
      bucketHot = true;
      bucketGlow = reduced() ? 0 : 1;
      if (!reduced()) {
        var i, a, sp;
        for (i = 0; i < 16; i++) {
          a = (-0.15 - Math.random() * 0.7) * Math.PI;
          sp = 3 + Math.random() * 6;
          sparks.push({ x: S.dist + (Math.random() - 0.5) * 1.5, y: 0.9,
                        vx: Math.cos(a) * sp * (Math.random() < 0.5 ? -1 : 1) * 0.6,
                        vy: Math.abs(Math.sin(a)) * sp, r: 1.5 + Math.random() * 1.6, life: 1 });
        }
      }
      setPill("ok", "in the bucket — " + fx(Math.abs(rec.off), 2) + " m off centre");
      say("In the bucket. Landed at " + fx(rec.range, 1) + " metres, " + fx(Math.abs(rec.off), 2) + " metres from the centre.");
    } else {
      var word = rec.off < 0 ? "short by " : "long by ";
      setPill("", word + fx(Math.abs(rec.off), 1) + " m");
      say("Landed at " + fx(rec.range, 1) + " metres, " + word + fx(Math.abs(rec.off), 1) + " metres.");
    }

    if (S.keep) {
      trails.push(rec);
      if (trails.length > 6) trails.shift();
    }
    S.sel = -1;
    renderShots();
    updateReadout();
    fitHeight();
    requestFrame();
  }

  function setPill(cls, text) {
    pill.className = "pill" + (cls ? " " + cls : "");
    setTxt(pillText, text);
  }

  /* ── panel rendering ────────────────────────────────────────────────────── */
  function unit(v, d, u) { return fx(v, d) + '<span class="u">' + u + "</span>"; }
  function setHTML(el, h) { if (el.innerHTML !== h) el.innerHTML = h; }

  function updateReadout() {
    var a = cur.ideal, b = cur.air;
    if (S.air) {
      tbl.classList.remove("solo");
      setTxt(thL, "no air"); setTxt(thR, "with air");
      setHTML(dL, unit(a.range, 1, "m"));  setHTML(dR, unit(b.range, 1, "m"));
      setHTML(pL, unit(a.peak, 1, "m"));   setHTML(pR, unit(b.peak, 1, "m"));
      setHTML(tL, unit(a.time, 2, "s"));   setHTML(tR, unit(b.time, 2, "s"));
      setHTML(vL, unit(a.vimp, 1, "m/s")); setHTML(vR, unit(b.vimp, 1, "m/s"));
      var lost = a.range - b.range, pct = a.range > 0 ? lost / a.range * 100 : 0;
      fCost.innerHTML = "Air is eating <b>" + fx(lost, 1) + " m</b> of this throw — " +
        fx(pct, 0) + "% of the distance the textbook parabola promised.";
    } else {
      tbl.classList.add("solo");
      setTxt(thL, "this shot");
      setHTML(dL, unit(a.range, 1, "m"));
      setHTML(pL, unit(a.peak, 1, "m"));
      setHTML(tL, unit(a.time, 2, "s"));
      setHTML(vL, unit(a.vimp, 1, "m/s"));
      fCost.innerHTML = "Air is <b>off</b> — this is the clean parabola, the same curve every physics book draws.";
    }

    var real = realShot();
    var gap = real.range - S.dist;
    fBucket.innerHTML = S.dist + " m &middot; <span class=\"dim\">" +
      (Math.abs(gap) <= HALF ? "in reach" : (gap < 0 ? fx(-gap, 1) + " m short" : fx(gap, 1) + " m long")) + "</span>";

    var bi = bestAngleIdeal(S.power);
    if (S.air) {
      fBest.innerHTML = fx(cur.best.angle, 0) + "° <span class=\"dim\">&middot; " + fx(bi, 0) + "° with no air</span>";
    } else {
      /* one decimal here: launching from 1.2 m shaves a fraction off 45°, and
         rounding it away would print "45° · 45°" and look like a bug */
      fBest.innerHTML = fx(bi, 1) + "° <span class=\"dim\">&middot; 45° from ground level</span>";
    }

    if (S.made > 0) {
      madeAux.innerHTML = '<span style="color:var(--run);font-weight:600">' + S.made + " in the bucket</span>";
    } else { madeAux.textContent = ""; }

    setTxt(vAngle, S.angle + "°");
    setTxt(vPower, fx(S.power, 1) + " m/s");
    setTxt(vDist, S.dist + " m");
    setTxt(fireLbl, S.angle + "° · " + fx(S.power, 1) + " m/s");

    var bl = BALLS[S.ball];
    fBall.textContent = (bl.m >= 1000 ? "1.0 kg" : bl.m + " g") + " · " + fx(bl.d, bl.d < 10 ? 1 : 0) + " cm across";
    fK.textContent = bl.k.toFixed(5).replace(/0+$/, "") + " /m";

    /* with air off the solid line IS the no-air path — relabel, don't lie */
    lgIdeal.className = "lg" + (S.air ? "" : " off");
    lgAir.className = "lg";
    lgAir.lastChild.nodeValue = S.air ? "with air" : "no air";
  }

  function renderShots() {
    shotAux.textContent = trails.length ? trails.length + "/6" : "";
    shotsEmpty.hidden = trails.length > 0;
    clearBtn.disabled = trails.length === 0;
    var html = "", i, t, nm;
    for (i = trails.length - 1; i >= 0; i--) {
      t = trails[i];
      nm = BALLS[t.ball].name.split(" ")[0].toLowerCase();
      html += '<li><button type="button" class="' + (t.hit ? "hit " : "") + (S.sel === t.id ? "sel" : "") +
        '" data-id="' + t.id + '">' +
        '<span class="n">#' + t.id + '</span>' +
        '<span class="d">' + t.angle + "° · " + fx(t.power, 0) + " m/s · " + nm + (t.air ? "" : " · no air") + "</span>" +
        '<span class="r">' + fx(t.range, 1) + " m" + (t.hit ? " ✓" : "") + "</span>" +
        "</button></li>";
    }
    shotsUl.innerHTML = html;
  }

  function renderBalls() {
    var html = "", i, b;
    for (i = 0; i < BALLS.length; i++) {
      b = BALLS[i];
      var dia = Math.max(7, Math.min(15, 6 + Math.sqrt(b.d) * 1.5));
      html += '<button type="button" data-i="' + i + '" aria-pressed="' + (S.ball === i) + '">' +
        '<span class="bd" style="width:' + dia + "px;height:" + dia + 'px"></span>' + b.name + "</button>";
    }
    ballsBox.innerHTML = html;
  }

  /* ── interaction ────────────────────────────────────────────────────────── */
  var hintGone = false;
  function hideHint() { if (!hintGone) { hintGone = true; hint.classList.add("gone"); } }

  /* held arrow keys fire ~30×/s; refitting the box on each one would shudder */
  var refitTimer = 0;
  function refitSoon() {
    if (refitTimer) clearTimeout(refitTimer);
    refitTimer = setTimeout(function () { refitTimer = 0; fitHeight(); }, 180);
  }

  function changed(announce, refit) {
    recompute();
    updateReadout();
    positionGrab();
    if (refit) fitHeight();
    requestFrame();
    if (announce) say(announce);
  }

  angleIn.addEventListener("input", function () {
    S.angle = +angleIn.value; if (!flight) setPill("", "aim changed — ready"); changed();
  });
  powerIn.addEventListener("input", function () {
    S.power = +powerIn.value; if (!flight) setPill("", "aim changed — ready"); changed();
  });
  distIn.addEventListener("input", function () {
    S.dist = +distIn.value; bucketHot = false; bucketGlow = 0; changed();
  });
  [angleIn, powerIn, distIn].forEach(function (el) {
    el.addEventListener("change", function () { fitHeight(); });
  });

  airBtn.addEventListener("click", function () {
    S.air = !S.air;
    airBtn.setAttribute("aria-pressed", String(S.air));
    airBtn.lastChild.nodeValue = S.air ? "Air is on" : "Air is off";
    changed(S.air ? "Air resistance on." : "Air resistance off — clean parabola.", true);
  });
  ghostBtn.addEventListener("click", function () {
    S.keep = !S.keep;
    ghostBtn.setAttribute("aria-pressed", String(S.keep));
    ghostBtn.lastChild.nodeValue = S.keep ? "Keep paths" : "Drop paths";
    if (!S.keep) { trails.length = 0; S.sel = -1; renderShots(); }
    changed(S.keep ? "Paths will be kept." : "Paths cleared.", true);
  });

  ballsBox.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-i]") : null;
    if (!b) return;
    S.ball = +b.getAttribute("data-i");
    var kids = ballsBox.querySelectorAll("button"), i;
    for (i = 0; i < kids.length; i++) kids[i].setAttribute("aria-pressed", String(i === S.ball));
    /* keep the bucket somewhere this ball can actually reach */
    var reach = maxReach(S.ball), note = "";
    if (S.dist > reach + HALF) {
      S.dist = clamp(Math.round(reach * 0.7), +distIn.min, +distIn.max);
      distIn.value = String(S.dist);
      bucketHot = false; bucketGlow = 0;
      note = " Bucket pulled in to " + S.dist + " metres — that is all this ball can throw.";
      setPill("", "bucket moved to " + S.dist + " m — " + BALLS[S.ball].name.toLowerCase() + " can't throw further");
    }
    changed(BALLS[S.ball].name + " selected." + note, true);
  });

  fireBtn.addEventListener("click", function () { launch(); });

  moveBtn.addEventListener("click", function () {
    var DMIN = +distIn.min, DMAX = +distIn.max;
    var now = realShot() ? realShot().range : 40;
    var cap = Math.min(DMAX, maxReach(S.ball));      /* never park it out of reach */
    var lo = Math.max(DMIN, Math.round(now * 0.45));
    var hi = Math.min(cap, Math.round(now * 1.35) + 6);
    if (hi <= lo) { lo = DMIN; hi = Math.max(DMIN + 1, Math.round(cap)); }
    var d = Math.round(lo + Math.random() * (hi - lo));
    if (d === S.dist) d = d + (d + 5 <= hi ? 5 : -5);
    S.dist = clamp(d, DMIN, DMAX);
    distIn.value = String(S.dist);
    bucketHot = false; bucketGlow = 0;
    setPill("", "bucket moved to " + S.dist + " m");
    changed("Bucket moved to " + S.dist + " metres.", true);
  });

  clearBtn.addEventListener("click", function () {
    trails.length = 0; S.sel = -1; renderShots(); fitHeight(); requestFrame();
    say("Paths cleared.");
  });

  shotsUl.addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-id]") : null;
    if (!b) return;
    var id = +b.getAttribute("data-id"), i, t = null;
    for (i = 0; i < trails.length; i++) if (trails[i].id === id) t = trails[i];
    if (!t) return;
    S.sel = id; S.angle = t.angle; S.power = t.power; S.ball = t.ball; S.air = t.air;
    angleIn.value = String(S.angle); powerIn.value = String(S.power);
    airBtn.setAttribute("aria-pressed", String(S.air));
    airBtn.lastChild.nodeValue = S.air ? "Air is on" : "Air is off";
    var kids = ballsBox.querySelectorAll("button");
    for (i = 0; i < kids.length; i++) kids[i].setAttribute("aria-pressed", String(i === S.ball));
    setPill("", "loaded shot #" + id);
    renderShots();
    changed("Loaded shot " + id + ": " + t.angle + " degrees, " + fx(t.power, 1) + " metres per second.", true);
  });

  /* drag-to-aim: pull the pouch back away from where you want the ball to go */
  var aiming = false, aimId = null, aimPx = 0, aimPy = 0, aimMoved = false, aimX0 = 0, aimY0 = 0;

  var grabAt = "";
  function positionGrab() {
    if (!scale) return;
    var key = Math.round(px(0)) + ":" + Math.round(py(H0));
    if (key === grabAt) return;
    grabAt = key;
    grab.style.left = Math.round(px(0)) + "px";
    grab.style.top = Math.round(py(H0)) + "px";
  }

  function aimFrom(clientX, clientY) {
    var r = cv.getBoundingClientRect();
    var lx = px(0), ly = py(H0);
    aimPx = clamp(clientX - r.left, 2, CW - 2);
    aimPy = clamp(clientY - r.top, 2, CH - 2);
    var dx = lx - (clientX - r.left), dy = (clientY - r.top) - ly;   /* pull back = aim forward */
    if (dx < 0.5) dx = 0.5;
    var ang = Math.atan2(Math.max(0, dy), dx) * 180 / Math.PI;
    if (dy <= 0) ang = 5;
    S.angle = Math.round(clamp(ang, 5, 85));
    var dist = Math.sqrt(dx * dx + dy * dy);
    var full = Math.max(60, Math.min(CW, CH) * 0.42);
    var f = clamp((dist - 12) / (full - 12), 0, 1);
    S.power = Math.round((8 + f * 26) * 2) / 2;
    angleIn.value = String(S.angle);
    powerIn.value = String(S.power);
    changed();
  }

  function startAim(e) {
    if (aiming || flight) return;
    aiming = true; aimId = e.pointerId; aimMoved = false;
    aimX0 = e.clientX; aimY0 = e.clientY;
    hideHint();
    setPill("busy", "pulling back…");
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
    requestFrame();
    /* preventDefault below suppresses the compatibility mousedown, and with it the
       implicit focus — so hand the field its focus by hand or the arrow keys die */
    e.preventDefault();
    try { cv.focus({ preventScroll: true }); } catch (err) { cv.focus(); }
  }
  function moveAim(e) {
    if (!aiming || e.pointerId !== aimId) return;
    /* a tap without a drag is just "launch as aimed" — don't yank the aim to it */
    if (!aimMoved && Math.abs(e.clientX - aimX0) + Math.abs(e.clientY - aimY0) < 7) return;
    aimMoved = true;
    aimFrom(e.clientX, e.clientY);
    e.preventDefault();
  }
  function endAim(e) {
    if (!aiming || e.pointerId !== aimId) return;
    var onBall = e.currentTarget === grab;
    aiming = false; aimId = null;
    try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
    /* a click on open field just focuses it; the ball itself always launches */
    if (!aimMoved && !onBall) { setPill("", "ready — arrows aim, Enter launches"); requestFrame(); return; }
    launch();
    e.preventDefault();
  }
  function cancelAim(e) {
    if (!aiming) return;
    aiming = false; aimId = null;
    setPill("", "aim cancelled");
    requestFrame();
  }

  [grab, cv].forEach(function (el) {
    el.addEventListener("pointerdown", function (e) {
      /* touch only aims from the grab handle, so the page still scrolls */
      if (el === cv && e.pointerType === "touch") return;
      startAim(e);
    });
    el.addEventListener("pointermove", moveAim);
    el.addEventListener("pointerup", endAim);
    el.addEventListener("pointercancel", cancelAim);
    /* released outside the window: without this the aim latches on forever */
    el.addEventListener("lostpointercapture", cancelAim);
  });

  cv.addEventListener("keydown", function (e) {
    var k = e.key, step = e.shiftKey ? 5 : 1, handled = true;
    if (k === "ArrowLeft")      { S.angle = clamp(S.angle - step, 5, 85); angleIn.value = String(S.angle); }
    else if (k === "ArrowRight"){ S.angle = clamp(S.angle + step, 5, 85); angleIn.value = String(S.angle); }
    else if (k === "ArrowUp")   { S.power = clamp(S.power + step * 0.5, 8, 34); powerIn.value = String(S.power); }
    else if (k === "ArrowDown") { S.power = clamp(S.power - step * 0.5, 8, 34); powerIn.value = String(S.power); }
    else if (k === "Enter" || k === " " || k === "Spacebar") { launch(); }
    else handled = false;
    if (handled) {
      e.preventDefault();
      hideHint();
      if (k !== "Enter" && k !== " " && k !== "Spacebar") {
        changed(S.angle + " degrees, " + fx(S.power, 1) + " metres per second.");
        refitSoon();
      }
    }
  });
  cv.addEventListener("focus", hideHint);

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readColors();
  renderBalls();
  renderShots();
  resize();
  recompute();
  computeScale();
  /* fit the box to the flight before the first paint, with no transition */
  field.style.transition = "none";
  fitHeight();
  void field.offsetHeight;
  field.style.transition = "";
  resize();
  computeScale();
  scale = scaleTarget;
  positionGrab();
  updateReadout();
  requestFrame();

  if (window.ResizeObserver) {
    var ro = new ResizeObserver(function () {
      var had = measured;
      resize(); computeScale(); positionGrab();
      /* width changed (or arrived at last, if we booted inside a hidden box) —
         the box has to be re-shaped to the flight, debounced so a drag is smooth */
      if (measured) { if (!had) { scale = scaleTarget; fitHeight(); } else refitSoon(); }
    });
    ro.observe(field);
  }
  if (!measured) {
    /* booted with no box at all (hidden tab, collapsed iframe). ResizeObserver
       usually catches the reveal, but it is not delivered in every embedding —
       poll for a short while so the field can never stay permanently blank. */
    var tries = 0, poll = setInterval(function () {
      resize();
      if (measured) { computeScale(); scale = scaleTarget; fitHeight(); positionGrab(); requestFrame(); }
      if (measured || ++tries > 100) clearInterval(poll);
    }, 120);
  }
  window.addEventListener("resize", function () { resize(); computeScale(); refitSoon(); });

  /* open already doing something: one shot lands just short of the bucket */
  setTimeout(function () { if (!flight && S.fired === 0) launch(true); }, reduced() ? 120 : 700);

})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong