{"project":{"id":"xGN4MuT","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Super Voxel Olympics","visible":true,"contributors":"","githubRepo":null,"forkedFrom":null,"isTemplate":false,"tags":"","files":{"folder":"","files":[{"name":"index.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Super Voxel Olympics</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;600;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"stylesheet\" href=\"css/styles.css\">\n</head>\n<body>\n<div id=\"app\">\n  <div id=\"vignette\"></div>\n\n  <div id=\"hud\" class=\"hidden\">\n    <div id=\"hud-top\">\n      <div id=\"event-chip\">\n        <span id=\"event-no\">EVENT 1 / 4</span>\n        <span id=\"event-name\">100M SPRINT</span>\n      </div>\n      <div id=\"timer\">0.00</div>\n    </div>\n    <div id=\"attempt-chip\" class=\"hidden\">ATTEMPT 1 / 2</div>\n    <div id=\"replay-badge\" class=\"hidden\">&#9679; REPLAY</div>\n    <div id=\"center-msg\"></div>\n    <div id=\"popup\"></div>\n    <div id=\"meters\">\n      <div id=\"angle-wrap\" class=\"hidden\">\n        <div id=\"angle-fill\"></div>\n        <span class=\"meter-label\" id=\"angle-label\">ANGLE 0&deg;</span>\n      </div>\n      <div id=\"power-wrap\">\n        <div id=\"power-fill\"></div>\n        <span class=\"meter-label\" id=\"power-label\">SPEED &mdash; MASH &#9664; &#9654;</span>\n      </div>\n    </div>\n  </div>\n\n  <div id=\"card\" class=\"overlay hidden\">\n    <div class=\"card-box\">\n      <div class=\"card-kicker\" id=\"card-no\">EVENT 1 OF 4</div>\n      <h2 id=\"card-name\">100M SPRINT</h2>\n      <p id=\"card-desc\"></p>\n      <div id=\"card-qual\" class=\"card-qual hidden\"></div>\n      <div id=\"card-records\"></div>\n      <div class=\"card-hint blink\">PRESS <b>ENTER</b> TO START</div>\n    </div>\n  </div>\n\n  <div id=\"name-entry\" class=\"overlay hidden\">\n    <div class=\"card-box\">\n      <div class=\"card-kicker\">ENTER YOUR INITIALS</div>\n      <div id=\"wheels\">\n        <div class=\"wheel\" id=\"wheel-0\"><span class=\"wl prev\"></span><span class=\"wl cur\"></span><span class=\"wl next\"></span></div>\n        <div class=\"wheel\" id=\"wheel-1\"><span class=\"wl prev\"></span><span class=\"wl cur\"></span><span class=\"wl next\"></span></div>\n        <div class=\"wheel\" id=\"wheel-2\"><span class=\"wl prev\"></span><span class=\"wl cur\"></span><span class=\"wl next\"></span></div>\n      </div>\n      <div class=\"card-hint blink\">&#9664; &#9654; SPIN &nbsp;&middot;&nbsp; <b>SPACE</b> LOCK LETTER</div>\n    </div>\n  </div>\n\n  <div id=\"event-select\" class=\"overlay hidden\">\n    <div class=\"card-box select-box\">\n      <div class=\"card-kicker\">CHOOSE YOUR EVENTS</div>\n      <div id=\"career-line\" class=\"career-line\"></div>\n      <div id=\"ev-grid\"></div>\n      <div id=\"ev-count\" class=\"ev-count\"></div>\n      <div class=\"card-hint\">&#9664; &#9654; MOVE &nbsp;&middot;&nbsp; <b>SPACE</b> TOGGLE &nbsp;&middot;&nbsp; <b>ENTER</b> START</div>\n      <button id=\"ev-start\" class=\"ev-start\">START THE GAMES</button>\n    </div>\n  </div>\n\n  <div id=\"results\" class=\"overlay hidden\">\n    <div class=\"card-box\">\n      <div class=\"card-kicker\" id=\"results-kicker\">RESULTS</div>\n      <h2 id=\"results-title\">100M SPRINT</h2>\n      <div id=\"results-table\"></div>\n      <div class=\"card-hint blink\" id=\"results-hint\">PRESS <b>ENTER</b> TO CONTINUE</div>\n    </div>\n  </div>\n\n  <div id=\"title\" class=\"overlay\">\n    <div class=\"title-box\">\n      <div class=\"title-kicker\">TRACK &middot; FIELD &middot; GLORY</div>\n      <h1>SUPER<br>VOXEL<br>OLYMPICS</h1>\n      <p class=\"title-sub\">A voxel love-letter to Track &amp; Field, Daley Thompson's Decathlon &amp; Sensible Soccer.</p>\n      <div class=\"card-hint blink\">PRESS <b>ENTER</b> TO BEGIN</div>\n      <div class=\"title-controls\">&#9664; &#9654; MASH TO RUN &nbsp;&middot;&nbsp; SPACE JUMP / THROW &nbsp;&middot;&nbsp; ENTER CONTINUE &nbsp;&middot;&nbsp; GAMEPAD OK</div>\n      <div class=\"title-controls touch-only\">TAP TO CONTINUE &nbsp;&middot;&nbsp; PADS TO RUN &nbsp;&middot;&nbsp; BEST IN LANDSCAPE</div>\n      <div id=\"title-standings\" class=\"title-standings hidden\"></div>\n    </div>\n  </div>\n\n  <div id=\"touch-ui\" class=\"hidden\">\n    <div id=\"t-left\">\n      <button id=\"t-run-l\" class=\"tbtn\">&#9664;</button>\n      <button id=\"t-run-r\" class=\"tbtn\">&#9654;</button>\n    </div>\n    <button id=\"t-action\" class=\"tbtn taction\">&#9650;</button>\n  </div>\n</div>\n\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js\",\n    \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/\"\n  }\n}\n</script>\n<script src=\"js/main.js\" type=\"module\"></script>\n   \n\n</body>\n</html>\n"},{"name":"README.md","content":"# Super Voxel Olympics\n\nA voxel 3D athletics game in the spirit of Konami's Track & Field, Daley\nThompson's Decathlon and Sensible Soccer. Top-down camera, a life-sized 400m\noval stadium, 8 competitors, world records, and a lot of button mashing.\n\n## Events (14)\n\n100m Sprint · Long Jump · Javelin · 110m Hurdles · High Jump · 200m ·\nTriple Jump · Shot Put · Pole Vault · 400m · Hammer Throw · Discus ·\n4×100m Relay · 800m\n\nAfter entering your initials you pick which events make up your games —\nthe selection is remembered between sessions (`svo-lineup-v1`).\n\n## Features\n\n- **Qualifying marks** — every event has a qualifying mark (shown on the\n  event card); hit it for +100 bonus points\n- **Slow-mo replay** — put a mark on the record board and the last few\n  seconds play back in slow motion before the results\n- **Career mode** — meets, medals and season points persist per initials\n  (`svo-career-v1`); season standings appear on the title screen\n- **Gamepad support** — d-pad or X/B to mash, A = SPACE, Start = ENTER\n- Records are saved in localStorage (`svo-records-v1`)\n\n## Controls\n\n- **◀ ▶ (or A/D)** — alternate rapidly to run / spin up throws\n- **SPACE** — jump hurdles, charge & release jump/throw angle, pass the\n  baton, plant the vault pole (release at the top of the swing!)\n- **ENTER** — advance screens\n- 200m/400m/800m have a **stamina** gauge — pace yourself\n- High Jump / Pole Vault: the bar keeps rising, three misses ends it\n\n## Code layout\n\n- `js/lib.js` — three.js + shared helpers\n- `js/world.js` — the stadium (track, stands, crowd, roof, props)\n- `js/athlete.js` — articulated voxel athlete + all animation modes\n- `js/props.js` — hurdles, javelin, hammer, discus\n- `js/core.js` — renderer, camera, roster, shared state\n- `js/hud.js`, `js/audio.js`, `js/fx.js`, `js/records.js`, `js/npcs.js`\n- `js/events/` — `races` (straights), `laps` (oval + relay), `jumps`, `throws`\n- `js/main.js` — game flow, input, post-FX, render loop\n"}],"folders":[{"folder":"css","files":[{"name":"styles.css","content":":root {\n  --accent: #ff4b3a;\n  --amber: #ffb020;\n  --lime: #7ce34a;\n  --ink: #0b1120;\n  --glass: rgba(10, 16, 30, 0.72);\n  --glass-line: rgba(255, 255, 255, 0.14);\n  font-size: 16px;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\nhtml, body {\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: #0b1120;\n  font-family: 'Space Grotesk', system-ui, sans-serif;\n  color: #fff;\n  user-select: none;\n  -webkit-user-select: none;\n}\n\n#app { position: fixed; inset: 0; }\n#app canvas { display: block; }\n\n#vignette {\n  position: absolute; inset: 0;\n  pointer-events: none;\n  background: radial-gradient(ellipse at center, transparent 58%, rgba(5, 9, 20, 0.42) 100%);\n  z-index: 5;\n}\n\n.hidden { display: none !important; }\n\n/* ---------- HUD ---------- */\n#hud { position: absolute; inset: 0; z-index: 10; pointer-events: none; }\n\n#hud-top {\n  position: absolute; top: 22px; left: 26px; right: 26px;\n  display: flex; justify-content: space-between; align-items: flex-start;\n}\n\n#event-chip {\n  display: flex; flex-direction: column; gap: 3px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-left: 4px solid var(--accent);\n  border-radius: 12px;\n  padding: 10px 18px 12px;\n  backdrop-filter: blur(8px);\n  box-shadow: 0 10px 30px rgba(0,0,0,.35);\n}\n#event-no {\n  font-size: 0.65rem; letter-spacing: 3px; font-weight: 700;\n  color: var(--amber);\n}\n#event-name {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: 1.25rem; letter-spacing: 1px;\n}\n\n#timer {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: 2.6rem;\n  letter-spacing: 1px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-radius: 14px;\n  padding: 6px 20px;\n  backdrop-filter: blur(8px);\n  font-variant-numeric: tabular-nums;\n  box-shadow: 0 10px 30px rgba(0,0,0,.35);\n}\n\n#attempt-chip {\n  position: absolute; top: 108px; left: 26px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-radius: 999px;\n  padding: 7px 16px;\n  font-size: 0.7rem; font-weight: 700; letter-spacing: 2.5px;\n  color: #cfe0ff;\n  backdrop-filter: blur(8px);\n}\n\n/* meters */\n#meters {\n  position: absolute; bottom: 34px; left: 50%;\n  transform: translateX(-50%);\n  display: flex; flex-direction: column; gap: 14px;\n  width: min(420px, 74vw);\n}\n#power-wrap, #angle-wrap {\n  position: relative;\n  height: 20px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-radius: 999px;\n  overflow: hidden;\n  backdrop-filter: blur(8px);\n  box-shadow: 0 10px 30px rgba(0,0,0,.35);\n}\n#power-fill {\n  height: 100%; width: 0%;\n  background: linear-gradient(90deg, var(--lime), var(--amber) 60%, var(--accent));\n  border-radius: 999px;\n  transition: width 60ms linear;\n}\n#angle-fill {\n  height: 100%; width: 0%;\n  background: linear-gradient(90deg, #46c6ff, #7c5bff);\n  border-radius: 999px;\n  transition: width 40ms linear;\n}\n.meter-label {\n  position: absolute; inset: 0;\n  display: grid; place-items: center;\n  font-size: 0.62rem; font-weight: 700; letter-spacing: 3px;\n  color: rgba(255,255,255,.92);\n  text-shadow: 0 1px 4px rgba(0,0,0,.6);\n}\n\n/* replay badge */\n#replay-badge {\n  position: absolute; top: 108px; right: 26px;\n  background: var(--glass);\n  border: 1px solid rgba(255,75,58,.6);\n  border-radius: 999px;\n  padding: 7px 16px;\n  font-size: 0.7rem; font-weight: 700; letter-spacing: 3px;\n  color: var(--accent);\n  backdrop-filter: blur(8px);\n  animation: blink 1s step-end infinite;\n}\n\n/* center message (countdown, GO, FOUL) */\n#center-msg {\n  position: absolute; top: 34%; left: 50%;\n  transform: translate(-50%, -50%);\n  font-family: 'Archivo Black', sans-serif;\n  font-size: clamp(3rem, 11vw, 8rem);\n  letter-spacing: 4px;\n  text-shadow: 0 6px 0 rgba(0,0,0,.28), 0 18px 60px rgba(0,0,0,.5);\n  opacity: 0;\n}\n#center-msg.pop {\n  animation: msgpop 0.9s cubic-bezier(.2,1.6,.35,1) forwards;\n}\n#center-msg.go { color: var(--lime); }\n#center-msg.bad { color: var(--accent); }\n@keyframes msgpop {\n  0%   { opacity: 0; transform: translate(-50%,-50%) scale(2.2); }\n  18%  { opacity: 1; transform: translate(-50%,-50%) scale(1); }\n  75%  { opacity: 1; }\n  100% { opacity: 0; transform: translate(-50%,-50%) scale(.92); }\n}\n\n/* floating popup (distance / clipped etc.) */\n#popup {\n  position: absolute; top: 46%; left: 50%;\n  transform: translateX(-50%);\n  font-family: 'Archivo Black', sans-serif;\n  font-size: clamp(1.6rem, 4.5vw, 3rem);\n  color: var(--amber);\n  text-shadow: 0 4px 0 rgba(0,0,0,.3), 0 12px 40px rgba(0,0,0,.5);\n  opacity: 0;\n  white-space: nowrap;\n}\n#popup.rise { animation: rise 1.6s ease-out forwards; }\n#popup.bad { color: var(--accent); }\n@keyframes rise {\n  0%   { opacity: 0; transform: translate(-50%, 26px) scale(.7); }\n  15%  { opacity: 1; transform: translate(-50%, 0) scale(1); }\n  70%  { opacity: 1; }\n  100% { opacity: 0; transform: translate(-50%, -46px) scale(.96); }\n}\n\n/* ---------- Overlays ---------- */\n.overlay {\n  position: absolute; inset: 0; z-index: 20;\n  display: grid; place-items: center;\n  background: radial-gradient(ellipse at center, rgba(6,10,22,.25) 0%, rgba(6,10,22,.68) 100%);\n  backdrop-filter: blur(3px);\n}\n\n.card-box, .title-box {\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-radius: 24px;\n  padding: 42px 58px;\n  text-align: center;\n  max-width: min(620px, 90vw);\n  box-shadow: 0 30px 90px rgba(0,0,0,.55);\n  backdrop-filter: blur(14px);\n  animation: cardin .45s cubic-bezier(.2,1.4,.4,1);\n}\n@keyframes cardin {\n  from { opacity: 0; transform: translateY(26px) scale(.96); }\n  to   { opacity: 1; transform: none; }\n}\n\n.card-kicker, .title-kicker {\n  font-size: 0.7rem; font-weight: 700;\n  letter-spacing: 5px;\n  color: var(--amber);\n  margin-bottom: 12px;\n}\n\n.card-box h2 {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: clamp(2rem, 6vw, 3.4rem);\n  letter-spacing: 2px;\n  line-height: 1.02;\n  background: linear-gradient(120deg, #fff 30%, #ffd9a0);\n  -webkit-background-clip: text;\n  background-clip: text;\n  -webkit-text-fill-color: transparent;\n  margin-bottom: 14px;\n}\n\n#card-desc {\n  font-size: 1rem; line-height: 1.55;\n  color: #c7d4ee;\n  margin-bottom: 26px;\n}\n\n.card-hint {\n  font-size: 0.78rem; font-weight: 700; letter-spacing: 3px;\n  color: #e8eefc;\n}\n.card-hint b { color: var(--lime); }\n.blink { animation: blink 1.4s step-end infinite; }\n@keyframes blink { 50% { opacity: 0.25; } }\n\n/* title screen — floats free over the stadium, no modal */\n#title {\n  background: transparent;\n  backdrop-filter: none;\n}\n#title .title-box {\n  background: transparent;\n  border: none;\n  box-shadow: none;\n  backdrop-filter: none;\n  padding: 0;\n  max-width: none;\n}\n.title-box h1 {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: clamp(3rem, 9.5vw, 5.4rem);\n  line-height: 0.95;\n  letter-spacing: 3px;\n  /* generous padding so background-clip:text never crops edge glyphs */\n  padding: 0.06em 0.2em;\n  background: linear-gradient(160deg, #ffffff 10%, #ffc46b 45%, var(--accent) 90%);\n  -webkit-background-clip: text;\n  background-clip: text;\n  -webkit-text-fill-color: transparent;\n  margin-bottom: 18px;\n  filter: drop-shadow(0 6px 0 rgba(0,0,0,.35)) drop-shadow(0 16px 34px rgba(0,0,0,.5));\n}\n.title-kicker,\n.title-sub,\n#title .card-hint,\n.title-controls {\n  text-shadow: 0 2px 10px rgba(0,0,0,.75), 0 0 30px rgba(0,0,0,.5);\n}\n.title-sub { color: #e6eeff; }\n.title-controls { color: #c6d4ee; }\n.title-sub {\n  color: #c7d4ee;\n  font-size: 0.95rem;\n  line-height: 1.5;\n  margin-bottom: 28px;\n}\n.title-controls {\n  margin-top: 22px;\n  font-size: 0.68rem; letter-spacing: 2.5px; font-weight: 600;\n  color: #8fa3c8;\n}\n\n/* ---------- initials wheels (Track & Field style) ---------- */\n#wheels {\n  display: flex;\n  justify-content: center;\n  gap: 22px;\n  margin: 26px 0 30px;\n}\n.wheel {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  width: 86px;\n  padding: 10px 0;\n  border-radius: 14px;\n  background: rgba(255,255,255,.05);\n  border: 1px solid rgba(255,255,255,.1);\n  overflow: hidden;\n}\n.wheel .wl {\n  font-family: 'Archivo Black', sans-serif;\n  line-height: 1.15;\n}\n.wheel .prev, .wheel .next {\n  font-size: 1.4rem;\n  color: rgba(255,255,255,.22);\n  transform: scale(.8);\n}\n.wheel .cur {\n  font-size: 3.2rem;\n  color: #fff;\n}\n.wheel.active {\n  border-color: var(--accent);\n  background: rgba(255,75,58,.14);\n  box-shadow: 0 0 24px rgba(255,75,58,.35);\n}\n.wheel.active .cur { color: var(--amber); }\n.wheel.locked .cur { color: var(--lime); }\n\n/* qualifying line on the event card */\n.card-qual {\n  display: inline-block;\n  margin: -12px 0 20px;\n  padding: 6px 16px;\n  border-radius: 999px;\n  background: rgba(255,176,32,.12);\n  border: 1px solid rgba(255,176,32,.4);\n  font-size: .68rem; font-weight: 700; letter-spacing: 2px;\n  color: var(--amber);\n}\n\n/* ---------- event record board ---------- */\n#card-records {\n  margin: 4px 0 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 5px;\n}\n.rec-row {\n  display: grid;\n  grid-template-columns: 30px 64px 1fr;\n  gap: 10px;\n  align-items: center;\n  padding: 6px 16px;\n  background: rgba(255,255,255,.05);\n  border: 1px solid rgba(255,255,255,.08);\n  border-radius: 9px;\n  font-size: .82rem;\n  font-weight: 700;\n  letter-spacing: 1px;\n}\n.rec-row .rp { font-family: 'Archivo Black', sans-serif; color: #8fa3c8; }\n.rec-row:nth-child(1) .rp { color: #ffd24a; }\n.rec-row:nth-child(2) .rp { color: #cfd8e6; }\n.rec-row:nth-child(3) .rp { color: #e0925a; }\n.rec-row .rn { color: var(--amber); }\n.rec-row .rv { text-align: right; font-variant-numeric: tabular-nums; color: #e8eefc; }\n\n/* ---------- touch controls ---------- */\n#touch-ui { position: fixed; inset: 0; z-index: 30; pointer-events: none; }\n#t-left {\n  position: absolute; left: 16px; bottom: 18px;\n  display: flex; gap: 14px;\n}\n.tbtn {\n  pointer-events: auto;\n  width: 84px; height: 84px;\n  border-radius: 50%;\n  border: 1px solid rgba(255,255,255,.28);\n  background: rgba(10,16,30,.55);\n  color: #fff;\n  font-size: 1.5rem;\n  backdrop-filter: blur(6px);\n  touch-action: none;\n  -webkit-tap-highlight-color: transparent;\n  user-select: none;\n  -webkit-user-select: none;\n}\n.tbtn:active {\n  background: rgba(255,75,58,.55);\n  transform: scale(.93);\n}\n.taction {\n  position: absolute; right: 18px; bottom: 18px;\n  width: 106px; height: 106px;\n  background: rgba(255,75,58,.35);\n  border-color: rgba(255,75,58,.65);\n}\n.touch-only { display: none; }\nbody.touch .touch-only { display: block; }\nbody.touch #meters { bottom: 128px; width: min(340px, 52vw); }\n\n/* ---------- event select ---------- */\n.select-box {\n  max-width: min(780px, 94vw);\n  max-height: 94vh;\n  overflow-y: auto;\n}\n#ev-grid {\n  display: grid;\n  grid-template-columns: repeat(2, minmax(0, 1fr));\n  gap: 8px;\n  margin: 18px 0 14px;\n  text-align: left;\n}\n.ev-item {\n  display: flex; align-items: center; gap: 12px;\n  padding: 9px 14px;\n  background: rgba(255,255,255,.05);\n  border: 1px solid rgba(255,255,255,.08);\n  border-radius: 12px;\n  font-weight: 700; letter-spacing: 1px; font-size: .88rem;\n  color: #8fa3c8;\n  cursor: pointer;\n}\n.ev-item .tick {\n  width: 20px; height: 20px; flex: none;\n  border-radius: 6px;\n  border: 2px solid rgba(255,255,255,.25);\n  display: grid; place-items: center;\n  font-size: .68rem; font-weight: 900;\n  color: transparent;\n}\n.ev-item.on { color: #fff; }\n.ev-item.on .tick { background: var(--lime); border-color: var(--lime); color: var(--ink); }\n.ev-item.cur {\n  border-color: var(--accent);\n  background: rgba(255,75,58,.14);\n  box-shadow: 0 0 18px rgba(255,75,58,.3);\n}\n.ev-count {\n  font-size: .72rem; font-weight: 700; letter-spacing: 3px;\n  color: var(--amber);\n  margin-bottom: 16px;\n}\n.ev-start {\n  display: block;\n  margin: 18px auto 0;\n  padding: 12px 34px;\n  border-radius: 999px;\n  border: 1px solid rgba(255,75,58,.65);\n  background: rgba(255,75,58,.22);\n  color: #fff;\n  font-family: inherit;\n  font-weight: 700; letter-spacing: 3px; font-size: .82rem;\n  cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n}\n.ev-start:hover, .ev-start:active { background: rgba(255,75,58,.45); }\n.career-line {\n  font-size: .74rem; font-weight: 700; letter-spacing: 1.5px;\n  color: #cfe0ff;\n  padding: 7px 14px;\n  border-radius: 999px;\n  background: rgba(255,255,255,.06);\n  border: 1px solid rgba(255,255,255,.1);\n  display: inline-block;\n}\n\n/* qualifying / career strips in results */\n.qual-strip, .career-strip {\n  padding: 9px 18px;\n  margin-bottom: 8px;\n  border-radius: 12px;\n  font-size: .74rem; font-weight: 700; letter-spacing: 2px;\n  background: rgba(124,227,74,.12);\n  border: 1px solid rgba(124,227,74,.45);\n  color: var(--lime);\n}\n.qual-strip.missed {\n  background: rgba(255,75,58,.1);\n  border-color: rgba(255,75,58,.4);\n  color: #ff8d80;\n}\n.career-strip {\n  margin-top: 12px;\n  margin-bottom: 0;\n  background: rgba(255,176,32,.1);\n  border-color: rgba(255,176,32,.4);\n  color: var(--amber);\n}\n\n/* season standings on the title */\n.title-standings {\n  margin-top: 20px;\n  display: inline-flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 12px 26px;\n  border-radius: 14px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  backdrop-filter: blur(8px);\n  font-size: .72rem; font-weight: 700; letter-spacing: 2px;\n}\n.title-standings .ts-k { color: var(--amber); letter-spacing: 4px; font-size: .62rem; margin-bottom: 3px; }\n.title-standings .ts-row { color: #e8eefc; font-variant-numeric: tabular-nums; }\n\n/* results table */\n#results-table { margin: 6px 0 26px; }\n.res-row {\n  display: grid;\n  grid-template-columns: 44px 1fr auto auto;\n  gap: 14px;\n  align-items: center;\n  padding: 10px 18px;\n  margin-bottom: 8px;\n  background: rgba(255,255,255,.05);\n  border: 1px solid rgba(255,255,255,.08);\n  border-radius: 12px;\n  text-align: left;\n  font-weight: 600;\n}\n.res-row.player {\n  border-color: rgba(255,75,58,.6);\n  background: rgba(255,75,58,.12);\n}\n.res-pos {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: 1.1rem;\n  color: #8fa3c8;\n}\n.res-row:nth-child(1) .res-pos { color: #ffd24a; }\n.res-row:nth-child(2) .res-pos { color: #cfd8e6; }\n.res-row:nth-child(3) .res-pos { color: #e0925a; }\n.res-name { letter-spacing: 1px; }\n.res-mark { font-variant-numeric: tabular-nums; color: #cfe0ff; }\n.res-pts {\n  font-family: 'Archivo Black', sans-serif;\n  color: var(--amber);\n  font-variant-numeric: tabular-nums;\n  min-width: 84px;\n  text-align: right;\n}\n.res-total { font-size: .72rem; color: #8fa3c8; margin-left: 6px; }\n\n/* ---------- small screens (last so it wins the cascade) ---------- */\n@media (max-width: 680px) {\n  #hud-top { top: 10px; left: 10px; right: 10px; }\n  #event-chip { padding: 6px 12px 8px; }\n  #event-name { font-size: 0.95rem; }\n  #event-no { font-size: 0.55rem; }\n  #timer { font-size: 1.7rem; padding: 3px 12px; }\n  #attempt-chip { top: 78px; left: 10px; font-size: 0.6rem; }\n  .card-box, .title-box { padding: 24px 20px; max-width: 94vw; }\n  .card-box h2 { font-size: 1.7rem; }\n  #card-desc { font-size: 0.85rem; margin-bottom: 16px; }\n  #card-records { margin-bottom: 14px; }\n  .rec-row { padding: 4px 12px; font-size: 0.72rem; }\n  .res-row { padding: 5px 10px; margin-bottom: 4px; font-size: 0.78rem; gap: 8px; grid-template-columns: 26px 1fr auto auto; }\n  .res-pts { min-width: 60px; }\n  .res-pos { font-size: 0.9rem; }\n  #replay-badge { top: 78px; right: 10px; font-size: 0.6rem; }\n  .card-qual { font-size: .58rem; margin: -8px 0 12px; padding: 4px 10px; }\n  .career-line { font-size: .6rem; padding: 5px 10px; }\n  .qual-strip, .career-strip { padding: 5px 10px; font-size: .62rem; margin-bottom: 4px; }\n  .title-standings { padding: 8px 16px; font-size: .62rem; margin-top: 12px; }\n  #ev-grid { gap: 5px; margin: 12px 0 10px; }\n  .ev-item { padding: 5px 9px; font-size: .66rem; gap: 8px; border-radius: 8px; }\n  .ev-item .tick { width: 14px; height: 14px; border-radius: 4px; font-size: .5rem; }\n  .ev-start { padding: 9px 24px; margin-top: 10px; font-size: .7rem; }\n  #wheels { gap: 12px; margin: 18px 0 20px; }\n  .wheel { width: 62px; padding: 6px 0; }\n  .wheel .cur { font-size: 2.1rem; }\n  .wheel .prev, .wheel .next { font-size: 1rem; }\n}\n"}],"folders":[]},{"folder":"js","files":[{"name":"athlete.js","content":"// athlete.js — Sensible-Soccer-proportioned voxel athlete: enormous head\n// (mostly hair from the top-down camera), no neck to speak of, stubby limbs,\n// solid kits, black boots. Fully articulated with joint caps and ankles so\n// the figure stays connected and fluid. ~1.78m world height.\nimport { THREE, boxMesh } from './lib.js';\n\nexport class Athlete {\n  constructor({\n    kit = '#ff4b3a', shorts = '#ffffff', skin = '#c98e5a', hair = '#20140e',\n    hairStyle = 'crop', name = 'YOU', num = 0,\n  } = {}) {\n    this.name = name;\n    this.group = new THREE.Group();\n    this.body = new THREE.Group();\n    this.group.add(this.body);\n    this.group.rotation.y = Math.PI / 2; // face +X\n    this.group.scale.setScalar(0.27);    // -> ~1.78m, stocky\n\n    // ----- stubby legs: hip cap -> thigh -> knee cap -> calf -> ankle -> boot\n    const buildLeg = () => {\n      const hip = new THREE.Group();\n      const hipCap = boxMesh(0.82, 0.82, 0.82, shorts);\n      hipCap.position.y = 0;\n      const thigh = boxMesh(0.78, 1.1, 0.78, skin);\n      thigh.position.y = -0.42;\n      const knee = new THREE.Group();\n      knee.position.y = -0.95;\n      const kneeCap = boxMesh(0.72, 0.72, 0.72, skin);\n      kneeCap.position.y = 0;\n      const calf = boxMesh(0.68, 0.95, 0.68, skin);\n      calf.position.y = -0.32;\n      const sock = boxMesh(0.72, 0.42, 0.72, shorts);\n      sock.position.y = -0.52;\n      const ankle = new THREE.Group();\n      ankle.position.y = -0.75;\n      const boot = boxMesh(0.95, 0.4, 1.3, '#22262c', { roughness: 0.55 }); // black boots!\n      boot.position.set(0, 0.02, 0.26);\n      const sole = boxMesh(0.98, 0.12, 1.33, '#dfe3e8', { roughness: 0.6 });\n      sole.position.set(0, -0.15, 0.26);\n      ankle.add(boot, sole);\n      knee.add(kneeCap, calf, sock, ankle);\n      hip.add(hipCap, thigh, knee);\n      return { hip, knee, ankle };\n    };\n    const lL = buildLeg(), lR = buildLeg();\n    this.legL = lL.hip; this.kneeL = lL.knee; this.ankleL = lL.ankle;\n    this.legR = lR.hip; this.kneeR = lR.knee; this.ankleR = lR.ankle;\n    this.legL.position.set(-0.52, 1.9, 0);\n    this.legR.position.set(0.52, 1.9, 0);\n\n    // ----- stocky trunk: solid shorts, solid kit, minimal trim\n    const shortsBox = boxMesh(1.95, 0.85, 1.15, shorts);\n    shortsBox.position.y = 2.1;\n    const torso = boxMesh(1.95, 1.5, 1.1, kit);\n    torso.position.y = 3.0;\n    const collar = boxMesh(1.15, 0.16, 0.92, shorts);\n    collar.position.y = 3.8;\n    const neck = boxMesh(0.7, 0.35, 0.7, skin); // barely there — Sensible style\n    neck.position.y = 3.85;\n\n    // ----- stubby arms: shoulder cap -> upper -> elbow cap -> forearm + fist\n    const buildArm = () => {\n      const shoulder = new THREE.Group();\n      const sleeve = boxMesh(0.72, 0.72, 0.72, kit);\n      sleeve.position.y = -0.1;\n      const upper = boxMesh(0.56, 0.85, 0.56, skin);\n      upper.position.y = -0.45;\n      const elbow = new THREE.Group();\n      elbow.position.y = -0.85;\n      const elbowCap = boxMesh(0.52, 0.52, 0.52, skin);\n      elbowCap.position.y = 0;\n      const forearm = boxMesh(0.5, 0.7, 0.5, skin);\n      forearm.position.y = -0.25;\n      const fist = boxMesh(0.6, 0.45, 0.6, skin);\n      fist.position.y = -0.68;\n      elbow.add(elbowCap, forearm, fist);\n      shoulder.add(sleeve, upper, elbow);\n      return { shoulder, elbow };\n    };\n    const aL = buildArm(), aR = buildArm();\n    this.armL = aL.shoulder; this.elbowL = aL.elbow;\n    this.armR = aR.shoulder; this.elbowR = aR.elbow;\n    this.armL.position.set(-1.3, 3.65, 0);\n    this.armR.position.set(1.3, 3.65, 0);\n\n    // ----- the enormous head, sat straight on the shoulders\n    this.head = new THREE.Group();\n    this.head.position.y = 3.95;\n    const headBox = boxMesh(2.2, 2.2, 2.2, skin);\n    headBox.position.y = 1.15;\n    // minimal sprite face: eyes + expressive brows, nothing else\n    this.eyeL = boxMesh(0.3, 0.34, 0.1, '#181818');\n    this.eyeL.position.set(-0.48, 1.25, 1.12);\n    this.eyeR = this.eyeL.clone();\n    this.eyeR.position.x = 0.48;\n    this.browL = boxMesh(0.42, 0.12, 0.08, hair);\n    this.browL.position.set(-0.48, 1.62, 1.14);\n    this.browR = this.browL.clone();\n    this.browR.position.x = 0.48;\n    this.head.add(headBox, this.eyeL, this.eyeR, this.browL, this.browR);\n\n    // hair dominates the top-down view — thick crown, fringe, and back\n    const addHair = (mesh) => this.head.add(mesh);\n    const fringe = boxMesh(2.28, 0.4, 0.2, hair);\n    fringe.position.set(0, 2.05, 1.08);\n    addHair(fringe);\n    if (hairStyle === 'afro') {\n      const fro = boxMesh(2.65, 1.3, 2.65, hair);\n      fro.position.set(0, 2.55, -0.05);\n      addHair(fro);\n      const froB = boxMesh(2.4, 0.9, 0.55, hair);\n      froB.position.set(0, 1.6, -1.1);\n      addHair(froB);\n    } else {\n      const crown = boxMesh(2.35, 0.65, 2.35, hair);\n      crown.position.set(0, 2.4, -0.05);\n      addHair(crown);\n      const hairBack = boxMesh(2.3, 1.15, 0.45, hair);\n      hairBack.position.set(0, 1.55, -1.06);\n      addHair(hairBack);\n      if (hairStyle === 'spike') {\n        for (const [sx, sz] of [[-0.65, 0.25], [0, -0.3], [0.65, 0.18]]) {\n          const spike = boxMesh(0.48, 0.55, 0.48, hair);\n          spike.position.set(sx, 2.95, sz);\n          addHair(spike);\n        }\n      } else if (hairStyle === 'pony') {\n        const tail = boxMesh(0.55, 1.5, 0.45, hair);\n        tail.position.set(0, 1.05, -1.45);\n        addHair(tail);\n      } else if (hairStyle === 'band') {\n        const band = boxMesh(2.4, 0.3, 2.4, kit);\n        band.position.set(0, 1.95, -0.05);\n        addHair(band);\n      }\n    }\n\n    this.body.add(\n      this.legL, this.legR, shortsBox, torso, collar, neck,\n      this.armL, this.armR, this.head\n    );\n\n    // race-number bib on the back\n    if (num > 0) {\n      const bc = document.createElement('canvas');\n      bc.width = 64; bc.height = 64;\n      const bctx = bc.getContext('2d');\n      bctx.fillStyle = '#f5f1e6';\n      bctx.fillRect(0, 0, 64, 64);\n      bctx.fillStyle = '#1a2233';\n      bctx.font = '900 42px \"Archivo Black\", sans-serif';\n      bctx.textAlign = 'center';\n      bctx.textBaseline = 'middle';\n      bctx.fillText(String(num), 32, 36);\n      const bibTex = new THREE.CanvasTexture(bc);\n      bibTex.colorSpace = THREE.SRGBColorSpace;\n      const bib = new THREE.Mesh(\n        new THREE.PlaneGeometry(0.9, 0.9),\n        new THREE.MeshStandardMaterial({ map: bibTex, roughness: 0.9 })\n      );\n      bib.position.set(0, 3.05, -0.575);\n      bib.rotation.y = Math.PI;\n      this.body.add(bib);\n    }\n\n    this.phase = Math.random() * Math.PI * 2;\n    this._tempo = 0.93 + Math.random() * 0.14;\n    this._impact = 0;\n    this._t = 0;\n    this._mt = 0;\n    this._lastMode = 'idle';\n    this._blink = 0;\n    this._nextBlink = 1.5 + Math.random() * 3;\n    this._s = {\n      hipL: 0, hipR: 0, kneeL: 0.08, kneeR: 0.08,\n      shL: 0, shR: 0, elL: -0.3, elR: -0.3, armZL: 0.12, armZR: -0.12,\n      bobY: 0, bobZ: 0, roll: 0, twist: 0,\n      hx: 0, hy: 0, hz: 0, sy: 1, sxz: 1,\n      browA: 0, browY: 0,\n    };\n  }\n\n  // mode: idle | run | air | crouch | celebrate | dejected | exhausted |\n  //       throwRun | charge | spin\n  update(dt, mode = 'idle', opt = {}) {\n    this._t += dt;\n    if (mode !== this._lastMode) {\n      if (this._lastMode === 'air') this._impact = 0.16;\n      this._lastMode = mode;\n      this._mt = 0;\n    }\n    this._mt += dt;\n    const t = this._t;\n    const mt = this._mt;\n\n    const P = {\n      hipL: 0, hipR: 0, kneeL: 0.08, kneeR: 0.08,\n      shL: 0, shR: 0, elL: -0.3, elR: -0.3, armZL: 0.12, armZR: -0.12,\n      bobY: 0, bobZ: 0, roll: 0, twist: 0,\n      hx: 0, hy: 0, hz: 0, sy: 1, sxz: 1,\n      browA: 0, browY: 0,\n    };\n    let lean = 0;\n\n    if (mode === 'run' || mode === 'throwRun') {\n      const speed = opt.speed || 0;\n      this.phase += dt * (4 + speed * 1.9) * this._tempo;\n      const amp = Math.min(1, speed / 9) * 1.15 + 0.15;\n      const s = Math.sin(this.phase);\n      const c = Math.cos(this.phase);\n      P.hipL = s * amp;\n      P.hipR = -s * amp;\n      P.kneeL = 0.3 * amp + Math.max(0, -c) * 1.5 * amp;\n      P.kneeR = 0.3 * amp + Math.max(0, c) * 1.5 * amp;\n      if (mode === 'throwRun') {\n        P.shL = s * amp * 0.85;\n        P.elL = -(0.8 + Math.abs(s) * 0.3);\n        P.shR = -2.4; P.elR = -0.35; P.armZR = -0.25; P.armZL = 0.2;\n      } else {\n        P.shL = -s * amp;\n        P.shR = s * amp;\n        P.elL = -(0.85 + Math.abs(s) * 0.35 * amp);\n        P.elR = -(0.85 + Math.abs(s) * 0.35 * amp);\n        P.armZL = 0.2; P.armZR = -0.2;\n      }\n      P.bobY = Math.abs(c) * 0.32 * amp;   // extra Sensible bounce\n      lean = Math.min(0.28, speed * 0.026);\n      P.roll = s * 0.05 * amp;\n      P.twist = s * 0.09 * amp;\n      P.hx = -lean * 0.5;\n      P.hz = -s * 0.09 * amp;\n      P.sy = 1 + Math.abs(c) * 0.05 * amp;\n      P.sxz = 1 - Math.abs(c) * 0.02 * amp;\n      P.browA = Math.min(0.3, speed * 0.03);\n      P.browY = -0.04;\n    } else if (mode === 'air') {\n      const pedal = Math.sin(t * 9);\n      P.hipL = 0.95 + pedal * 0.25;  P.kneeL = 0.4;\n      P.hipR = -0.8 - pedal * 0.25;  P.kneeR = 1.5;\n      P.shL = -2.3; P.shR = -1.5; P.elL = -0.6; P.elR = -0.9;\n      P.armZL = -0.35; P.armZR = 0.35;\n      lean = 0.12;\n      P.hx = -0.25;\n      P.sy = 1.07; P.sxz = 0.96;\n      P.browY = 0.1;\n    } else if (mode === 'crouch') {\n      P.bobY = -0.8;\n      P.hipL = 1.25; P.kneeL = 1.7;\n      P.hipR = -0.45; P.kneeR = 0.55;\n      P.shL = -0.9; P.shR = 0.6; P.elL = -0.5; P.elR = -0.4;\n      lean = 0.5;\n      P.hx = -0.55;\n      P.sy = 0.9; P.sxz = 1.06;\n      P.browA = 0.3;\n    } else if (mode === 'celebrate') {\n      const seg = Math.floor(mt / 1.7) % 4;\n      const tp = (mt % 1.7) / 1.7;\n      P.browY = 0.12;\n      if (seg === 0) {\n        const hop = Math.abs(Math.sin(mt * 6.5));\n        const pump = Math.sin(mt * 10);\n        P.bobY = hop * 0.95;\n        P.shL = -2.9 + pump * 0.6; P.shR = -2.9 - pump * 0.6;\n        P.elL = -0.45; P.elR = -0.45;\n        P.armZL = -0.5; P.armZR = 0.5;\n        P.hipL = hop * 0.35; P.hipR = -hop * 0.35;\n        P.kneeL = hop * 0.5; P.kneeR = hop * 0.5;\n        P.hz = Math.sin(mt * 5) * 0.22;\n        P.hx = -0.2;\n        P.sy = 1 + Math.cos(mt * 6.5) * 0.06;\n      } else if (seg === 1) {\n        P.bobY = Math.abs(Math.sin(mt * 3)) * 0.25;\n        P.shL = -2.75; P.shR = -2.75;\n        P.elL = -0.12; P.elR = -0.12;\n        P.armZL = -0.75; P.armZR = 0.75;\n        P.hx = -0.5;\n        lean = -0.12;\n        P.hipL = 0.1; P.hipR = -0.1;\n      } else if (seg === 2) {\n        const sway = Math.sin(mt * 6);\n        P.twist = sway * 0.55;\n        P.bobY = Math.abs(Math.sin(mt * 6)) * 0.35;\n        P.shL = -2.2; P.shR = -2.2;\n        P.elL = -1.15; P.elR = -1.15;\n        P.armZL = -0.35; P.armZR = 0.35;\n        P.hz = sway * 0.15;\n        P.kneeL = 0.4; P.kneeR = 0.4;\n        P.roll = sway * 0.06;\n      } else {\n        const th = -Math.PI * 2 * tp;\n        const H = 2.6; // waist height on the stocky build\n        P.bobY = Math.sin(Math.PI * tp) * 1.6 + H * (1 - Math.cos(th));\n        P.bobZ = -H * Math.sin(th);\n        lean = th;\n        P.hipL = 1.1; P.hipR = 1.1;\n        P.kneeL = 1.9; P.kneeR = 1.9;\n        P.shL = -0.5; P.shR = -0.5;\n        P.elL = -1.8; P.elR = -1.8;\n        P.sy = 0.95; P.sxz = 1.02;\n        P.hx = -0.3;\n      }\n    } else if (mode === 'dejected') {\n      const seg = Math.floor(mt / 2.2) % 2;\n      const w = Math.sin(mt * 1.2);\n      P.browA = -0.35;\n      P.browY = 0.06;\n      if (seg === 0) {\n        P.bobY = -0.3;\n        P.hipL = 0.12; P.hipR = -0.08;\n        P.kneeL = 0.25; P.kneeR = 0.2;\n        P.shL = 0.45; P.shR = 0.45;\n        P.elL = -0.15; P.elR = -0.15;\n        P.armZL = 0.05; P.armZR = -0.05;\n        lean = 0.3;\n        P.hx = 0.6;\n        P.hz = w * 0.06;\n        P.roll = w * 0.03;\n      } else {\n        P.bobY = -0.18;\n        P.shL = -2.6; P.shR = -2.6;\n        P.elL = -2.0; P.elR = -2.0;\n        P.armZL = -0.2; P.armZR = 0.2;\n        P.hx = 0.45;\n        P.hz = w * 0.08;\n        P.roll = w * 0.04;\n        P.kneeL = 0.2; P.kneeR = 0.2;\n      }\n    } else if (mode === 'exhausted') {\n      const breath = Math.sin(mt * 4.2);\n      P.bobY = -0.5;\n      P.hipL = 0.35; P.hipR = 0.35;\n      P.kneeL = 0.55; P.kneeR = 0.55;\n      P.shL = 0.95; P.shR = 0.95;\n      P.elL = -0.25; P.elR = -0.25;\n      P.armZL = 0.15; P.armZR = -0.15;\n      lean = 0.55;\n      P.hx = 0.35 + breath * 0.1;\n      P.sy = 1 + breath * 0.035;\n      P.browA = -0.2;\n      P.browY = 0.1;\n    } else if (mode === 'charge') {\n      P.bobY = -0.3;\n      P.hipL = 0.7; P.kneeL = 0.9;\n      P.hipR = -0.25; P.kneeR = 0.35;\n      P.shL = -0.7; P.elL = -0.5;\n      P.shR = opt.jav ? -2.6 : -0.7; P.elR = opt.jav ? -0.3 : -0.5;\n      lean = -0.14;\n      P.hx = -0.3;\n      P.sy = 0.95; P.sxz = 1.03;\n      P.browA = 0.35;\n    } else if (mode === 'spin') {\n      P.bobY = -0.45;\n      P.hipL = 0.5; P.kneeL = 0.6;\n      P.hipR = -0.3; P.kneeR = 0.5;\n      P.shL = -1.5; P.shR = -1.5; P.elL = -0.25; P.elR = -0.25;\n      P.armZL = -0.15; P.armZR = 0.15;\n      lean = 0.14;\n      P.hz = 0.1;\n      P.browA = 0.3;\n    } else { // idle — shifty and alive, waves at the crowd now and then\n      const w = Math.sin(t * 1.15 + this.phase);\n      P.bobY = Math.sin(t * 2.4 + this.phase) * 0.08;\n      P.shL = Math.sin(t * 2.4 + this.phase) * 0.12;\n      P.shR = -Math.sin(t * 2.4 + this.phase) * 0.12;\n      P.roll = w * 0.045;\n      P.hy = Math.sin(t * 0.65 + this.phase * 2) * 0.55;\n      P.hz = w * 0.05;\n      const waveCycle = (t + this.phase * 3) % 9;\n      if (waveCycle < 1.6) {\n        const k = Math.min(1, waveCycle / 0.3) * Math.min(1, (1.6 - waveCycle) / 0.3);\n        P.shR = -2.6 * k;\n        P.elR = (-0.4 + Math.sin(t * 11) * 0.4) * k;\n        P.armZR = 0.4 * k;\n        P.hy = 0.3 * k;\n        P.hz = 0.12 * k;\n        P.browY = 0.1 * k;\n      }\n    }\n\n    if (this._impact > 0) {\n      this._impact -= dt;\n      P.sy *= 0.85;\n      P.sxz *= 1.1;\n    }\n\n    const fast = mode === 'run' || mode === 'throwRun' || mode === 'spin' || mode === 'air';\n    const k = Math.min(1, dt * (fast ? 30 : 12));\n    const s = this._s;\n    for (const key in s) s[key] += (P[key] - s[key]) * k;\n\n    // blinking (all modes)\n    this._blink += dt;\n    if (this._blink > this._nextBlink + 0.13) {\n      this._blink = 0;\n      this._nextBlink = 1.5 + Math.random() * 3;\n    }\n    const blinking = this._blink > this._nextBlink;\n    this.eyeL.scale.y = this.eyeR.scale.y = blinking ? 0.12 : 1;\n\n    this.browL.rotation.z = -s.browA;\n    this.browR.rotation.z = s.browA;\n    this.browL.position.y = this.browR.position.y = 1.62 + s.browY;\n\n    this.legL.rotation.x = s.hipL;\n    this.legR.rotation.x = s.hipR;\n    this.kneeL.rotation.x = s.kneeL;\n    this.kneeR.rotation.x = s.kneeR;\n    this.ankleL.rotation.x = Math.max(-1.1, Math.min(1.1, -(s.hipL + s.kneeL) * 0.5));\n    this.ankleR.rotation.x = Math.max(-1.1, Math.min(1.1, -(s.hipR + s.kneeR) * 0.5));\n    this.armL.rotation.x = s.shL;\n    this.armR.rotation.x = s.shR;\n    this.elbowL.rotation.x = s.elL;\n    this.elbowR.rotation.x = s.elR;\n    this.armL.rotation.z = s.armZL;\n    this.armR.rotation.z = s.armZR;\n    this.body.position.y = s.bobY;\n    this.body.position.z = s.bobZ;\n    this.body.rotation.x = lean;\n    this.body.rotation.z = s.roll;\n    this.body.rotation.y = s.twist;\n    this.body.scale.set(s.sxz, s.sy, s.sxz);\n    this.head.rotation.set(s.hx, s.hy, s.hz);\n  }\n}\n"},{"name":"audio.js","content":"// audio.js — the stadium soundscape, all synthesized in WebAudio:\n//  - warm crowd murmur that breathes (brown noise + slow LFO, no hiss)\n//  - random whoops / shouts / whistles / clap bursts / air horns, stereo-\n//    panned around the bowl, more frequent as excitement rises\n//  - cheers on a dedicated swell channel\n//  - footsteps synced to the player's stride\nexport const audio = {\n  ctx: null, master: null, murmurGain: null, cheerGain: null,\n  _ambT: 0,\n\n  ensure() {\n    if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }\n    const AC = window.AudioContext || window.webkitAudioContext;\n    if (!AC) return;\n    this.ctx = new AC();\n    this.master = this.ctx.createGain();\n    this.master.gain.value = 1;\n    this.master.connect(this.ctx.destination);\n\n    // shared noise buffers\n    const sr = this.ctx.sampleRate;\n    const len = sr * 2;\n    this._white = this.ctx.createBuffer(1, len, sr);\n    const wd = this._white.getChannelData(0);\n    for (let i = 0; i < len; i++) wd[i] = Math.random() * 2 - 1;\n    this._brown = this.ctx.createBuffer(1, len, sr);\n    const bd = this._brown.getChannelData(0);\n    let lastV = 0;\n    for (let i = 0; i < len; i++) {\n      lastV = (lastV + 0.02 * (Math.random() * 2 - 1)) / 1.02;\n      bd[i] = lastV * 3.5;\n    }\n\n    // murmur bed: brown noise, warm lowpass, slow breathing LFO\n    const mSrc = this.ctx.createBufferSource();\n    mSrc.buffer = this._brown; mSrc.loop = true;\n    const mLp = this.ctx.createBiquadFilter();\n    mLp.type = 'lowpass'; mLp.frequency.value = 340; mLp.Q.value = 0.4;\n    this.murmurGain = this.ctx.createGain();\n    this.murmurGain.gain.value = 0.05;\n    mSrc.connect(mLp).connect(this.murmurGain).connect(this.master);\n    mSrc.start();\n    const lfo = this.ctx.createOscillator();\n    lfo.frequency.value = 0.13;\n    const lfoAmt = this.ctx.createGain();\n    lfoAmt.gain.value = 0.018;\n    lfo.connect(lfoAmt).connect(this.murmurGain.gain);\n    lfo.start();\n\n    // cheer swell channel\n    const cSrc = this.ctx.createBufferSource();\n    cSrc.buffer = this._white; cSrc.loop = true;\n    const cBp = this.ctx.createBiquadFilter();\n    cBp.type = 'bandpass'; cBp.frequency.value = 850; cBp.Q.value = 0.6;\n    this.cheerGain = this.ctx.createGain();\n    this.cheerGain.gain.value = 0;\n    cSrc.connect(cBp).connect(this.cheerGain).connect(this.master);\n    cSrc.start();\n  },\n\n  _out(pan) {\n    // panned route into the master bus\n    if (!this.ctx.createStereoPanner) return this.master;\n    const p = this.ctx.createStereoPanner();\n    p.pan.value = pan != null ? pan : (Math.random() * 2 - 1) * 0.8;\n    p.connect(this.master);\n    return p;\n  },\n\n  _noiseHit({ buf = 'white', dur = 0.2, type = 'bandpass', freq = 800, q = 1, vol = 0.1, pan = null, attack = 0.01 }) {\n    if (!this.ctx) return;\n    const src = this.ctx.createBufferSource();\n    src.buffer = buf === 'brown' ? this._brown : this._white;\n    src.loop = true;\n    src.playbackRate.value = 0.9 + Math.random() * 0.2;\n    const f = this.ctx.createBiquadFilter();\n    f.type = type; f.frequency.value = freq; f.Q.value = q;\n    const g = this.ctx.createGain();\n    const t = this.ctx.currentTime;\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.linearRampToValueAtTime(vol, t + attack);\n    g.gain.exponentialRampToValueAtTime(0.0001, t + attack + dur);\n    src.connect(f).connect(g).connect(this._out(pan));\n    src.start(); src.stop(t + attack + dur + 0.05);\n  },\n\n  beep(freq = 440, dur = 0.14, type = 'square', vol = 0.16) {\n    if (!this.ctx) return;\n    const o = this.ctx.createOscillator();\n    const g = this.ctx.createGain();\n    o.type = type; o.frequency.value = freq;\n    g.gain.setValueAtTime(vol, this.ctx.currentTime);\n    g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + dur);\n    o.connect(g).connect(this.master);\n    o.start(); o.stop(this.ctx.currentTime + dur);\n  },\n\n  tick() { this.beep(1300, 0.05, 'square', 0.08); },\n\n  gun() {\n    if (!this.ctx) return;\n    // crack + low body\n    this._noiseHit({ dur: 0.16, type: 'highpass', freq: 900, vol: 0.4, pan: 0, attack: 0.002 });\n    const o = this.ctx.createOscillator();\n    const g = this.ctx.createGain();\n    o.type = 'sine';\n    const t = this.ctx.currentTime;\n    o.frequency.setValueAtTime(120, t);\n    o.frequency.exponentialRampToValueAtTime(35, t + 0.22);\n    g.gain.setValueAtTime(0.5, t);\n    g.gain.exponentialRampToValueAtTime(0.001, t + 0.25);\n    o.connect(g).connect(this.master);\n    o.start(); o.stop(t + 0.26);\n  },\n\n  cheer() {\n    if (!this.ctx || !this.cheerGain) return;\n    const t = this.ctx.currentTime;\n    this.cheerGain.gain.cancelScheduledValues(t);\n    this.cheerGain.gain.setValueAtTime(Math.max(this.cheerGain.gain.value, 0.001), t);\n    this.cheerGain.gain.linearRampToValueAtTime(0.5, t + 0.12);\n    this.cheerGain.gain.exponentialRampToValueAtTime(0.001, t + 3.6);\n    // a few voices leap out of the roar\n    for (let i = 0; i < 4; i++) setTimeout(() => this.whoop(0.09), i * 160 + Math.random() * 120);\n    setTimeout(() => this.clapBurst(7), 250);\n  },\n\n  thud() {\n    this.beep(80, 0.18, 'sine', 0.32);\n    this._noiseHit({ buf: 'brown', dur: 0.12, type: 'lowpass', freq: 250, vol: 0.25, pan: 0, attack: 0.004 });\n  },\n\n  whoosh() {\n    if (!this.ctx) return;\n    const src = this.ctx.createBufferSource();\n    src.buffer = this._white; src.loop = true;\n    const f = this.ctx.createBiquadFilter();\n    f.type = 'bandpass'; f.Q.value = 1.2;\n    const t = this.ctx.currentTime;\n    f.frequency.setValueAtTime(300, t);\n    f.frequency.exponentialRampToValueAtTime(1600, t + 0.28);\n    const g = this.ctx.createGain();\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.linearRampToValueAtTime(0.12, t + 0.08);\n    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.32);\n    src.connect(f).connect(g).connect(this.master);\n    src.start(); src.stop(t + 0.36);\n  },\n\n  // footstep on the track — quiet tap + tiny thump, pitch-jittered\n  step() {\n    if (!this.ctx) return;\n    this._noiseHit({ dur: 0.045, type: 'lowpass', freq: 420 + Math.random() * 160, vol: 0.055, pan: 0, attack: 0.003 });\n    this.beep(85 + Math.random() * 25, 0.05, 'sine', 0.05);\n  },\n\n  // ---- crowd one-shots, panned around the bowl\n  whoop(vol = 0.07) {\n    if (!this.ctx) return;\n    const o = this.ctx.createOscillator();\n    o.type = 'triangle';\n    const t = this.ctx.currentTime;\n    const f0 = 320 + Math.random() * 220;\n    o.frequency.setValueAtTime(f0, t);\n    o.frequency.exponentialRampToValueAtTime(f0 * (1.8 + Math.random() * 0.5), t + 0.18);\n    o.frequency.exponentialRampToValueAtTime(f0 * 1.2, t + 0.38);\n    const g = this.ctx.createGain();\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.linearRampToValueAtTime(vol, t + 0.05);\n    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.42);\n    o.connect(g).connect(this._out());\n    o.start(); o.stop(t + 0.45);\n  },\n\n  shout() {\n    this._noiseHit({ dur: 0.28, type: 'bandpass', freq: 550 + Math.random() * 750, q: 1.6, vol: 0.05 + Math.random() * 0.045, attack: 0.02 });\n  },\n\n  whistle() {\n    if (!this.ctx) return;\n    const base = 2100 + Math.random() * 700;\n    for (let i = 0; i < 2; i++) {\n      setTimeout(() => this.beep(base + (Math.random() - 0.5) * 120, 0.09, 'sine', 0.035), i * 150);\n    }\n  },\n\n  clapBurst(n = 5) {\n    if (!this.ctx) return;\n    let at = 0;\n    for (let i = 0; i < n; i++) {\n      at += 45 + Math.random() * 55;\n      setTimeout(() => this._noiseHit({ dur: 0.022, type: 'highpass', freq: 1600, vol: 0.05, attack: 0.002 }), at);\n    }\n  },\n\n  horn() {\n    if (!this.ctx) return;\n    const t = this.ctx.currentTime;\n    const g = this.ctx.createGain();\n    g.gain.setValueAtTime(0.0001, t);\n    g.gain.linearRampToValueAtTime(0.055, t + 0.06);\n    g.gain.setValueAtTime(0.055, t + 0.55);\n    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.75);\n    const lp = this.ctx.createBiquadFilter();\n    lp.type = 'lowpass'; lp.frequency.value = 900;\n    const out = this._out();\n    lp.connect(g).connect(out);\n    for (const f of [233, 236.5]) {\n      const o = this.ctx.createOscillator();\n      o.type = 'sawtooth'; o.frequency.value = f;\n      o.connect(lp);\n      o.start(); o.stop(t + 0.8);\n    }\n  },\n\n  // called every frame: murmur level + random crowd chatter\n  setAmbience(excitement) {\n    if (this.murmurGain) this.murmurGain.gain.value = 0.05 + excitement * 0.16;\n  },\n\n  ambient(dt, excitement) {\n    if (!this.ctx) return;\n    this._ambT -= dt;\n    if (this._ambT > 0) return;\n    this._ambT = 0.35 + Math.random() * Math.max(0.5, 2.8 - excitement * 2.2);\n    const r = Math.random();\n    if (excitement > 0.55 && r < 0.07) this.horn();\n    else if (r < 0.42) this.whoop();\n    else if (r < 0.72) this.shout();\n    else if (r < 0.83) this.whistle();\n    else this.clapBurst(3 + Math.floor(Math.random() * 3 + excitement * 4));\n  },\n\n  // the WORLD RECORD ditty — dah dah da-da dah!\n  fanfare() {\n    if (!this.ctx) return;\n    const notes = [\n      [523.25, 0.00, 0.16],\n      [523.25, 0.20, 0.16],\n      [587.33, 0.40, 0.11],\n      [659.25, 0.52, 0.11],\n      [783.99, 0.66, 0.55],\n    ];\n    for (const [f, at, dur] of notes) {\n      setTimeout(() => {\n        this.beep(f, dur, 'square', 0.2);\n        this.beep(f / 2, dur, 'triangle', 0.14);\n      }, at * 1000);\n    }\n  },\n};\n"},{"name":"career.js","content":"// career.js — persistent per-initials season stats across meets\nconst KEY = 'svo-career-v1';\n\nfunction load() {\n  try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; }\n  catch (e) { return {}; }\n}\n\nexport function recordMeet(initials, placement, pts) {\n  const c = load();\n  const s = c[initials] || { meets: 0, golds: 0, silvers: 0, bronzes: 0, seasonPts: 0, bestMeet: 0 };\n  s.meets++;\n  if (placement === 1) s.golds++;\n  else if (placement === 2) s.silvers++;\n  else if (placement === 3) s.bronzes++;\n  s.seasonPts += pts;\n  s.bestMeet = Math.max(s.bestMeet, pts);\n  c[initials] = s;\n  try { localStorage.setItem(KEY, JSON.stringify(c)); } catch (e) { /* session-only */ }\n  return s;\n}\n\nexport function get(initials) { return load()[initials] || null; }\n\nexport function standings(limit = 3) {\n  return Object.entries(load())\n    .sort((a, b) => b[1].seasonPts - a[1].seasonPts)\n    .slice(0, limit);\n}\n\nexport function fmtLine(initials, s) {\n  return initials + ' — MEET ' + s.meets +\n    ' · \\u{1F947}' + s.golds + ' \\u{1F948}' + s.silvers + ' \\u{1F949}' + s.bronzes +\n    ' · SEASON ' + s.seasonPts.toLocaleString() + ' PTS';\n}\n"},{"name":"core.js","content":"// core.js — renderer, scene, camera, shared game state, roster\nimport { THREE, clamp } from './lib.js';\nimport { buildStadium, CENTER } from './world.js';\nimport { Athlete } from './athlete.js';\n\nexport const app = document.getElementById('app');\n\nexport const IS_TOUCH = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;\n\nexport const renderer = new THREE.WebGLRenderer({ antialias: true });\nrenderer.setPixelRatio(Math.min(window.devicePixelRatio, IS_TOUCH ? 1.3 : 1.75));\nrenderer.setSize(window.innerWidth, window.innerHeight);\nrenderer.shadowMap.enabled = true;\nrenderer.shadowMap.type = THREE.PCFSoftShadowMap;\nrenderer.toneMapping = THREE.ACESFilmicToneMapping;\nrenderer.toneMappingExposure = 1.12;\nrenderer.outputColorSpace = THREE.SRGBColorSpace;\napp.insertBefore(renderer.domElement, app.firstChild);\n\nexport const scene = new THREE.Scene();\nexport const camera = new THREE.PerspectiveCamera(30, window.innerWidth / window.innerHeight, 1, 1200);\ncamera.position.set(0, 170, 210);\ncamera.lookAt(CENTER);\n\nwindow.addEventListener('resize', () => {\n  camera.aspect = window.innerWidth / window.innerHeight;\n  camera.updateProjectionMatrix();\n  renderer.setSize(window.innerWidth, window.innerHeight);\n});\n\nexport const stadium = buildStadium(scene);\n\nexport const ROSTER = [\n  { name: 'YOU', kit: '#ff4b3a', shorts: '#ffffff', skin: '#c98e5a', hair: '#20140e', player: true },\n  { name: 'DALEY T.', kit: '#2563eb', shorts: '#ffffff', skin: '#8a5a33', hair: '#101010' },\n  { name: 'HYPER K.', kit: '#10b981', shorts: '#111827', skin: '#e8b48c', hair: '#3b2b1a', hairStyle: 'spike' },\n  { name: 'SENSI S.', kit: '#f59e0b', shorts: '#111827', skin: '#f0c8a0', hair: '#5b3a1e', hairStyle: 'band' },\n  { name: 'TURBO T.', kit: '#c026d3', shorts: '#ffffff', skin: '#6b4226', hair: '#101010', hairStyle: 'afro' },\n  { name: 'BLAZE B.', kit: '#0ea5e9', shorts: '#0b2540', skin: '#e8b48c', hair: '#c9c9c9' },\n  { name: 'MASH M.', kit: '#1f2937', shorts: '#ffffff', skin: '#f0c8a0', hair: '#7a3b12', hairStyle: 'spike' },\n  { name: 'ROCKET R.', kit: '#f5f1e6', shorts: '#274690', skin: '#8a5a33', hair: '#2a1b12', hairStyle: 'pony' },\n];\n\nexport const athletes = ROSTER.map((r, i) => {\n  const a = new Athlete({ ...r, num: i + 1 });\n  a.player = !!r.player;\n  scene.add(a.group);\n  return a;\n});\nexport const player = athletes[0];\n\n// per-event prop group — cleared between events\nexport const props = new THREE.Group();\nscene.add(props);\nexport function clearProps() { while (props.children.length) props.remove(props.children[0]); }\n\n// ---------------- shared game state\nexport const game = {\n  state: 'title',       // title | card | countdown | play | results | podium\n  eventIndex: 0,\n  totals: {},\n  timeScale: 1, tsTarget: 1, tsTimer: 0,\n  excitement: 0.2,\n  cd: { t: 0, step: -1 },\n  shake: 0,\n  time: 0,\n};\nROSTER.forEach((r) => { game.totals[r.name] = 0; });\n\nexport const cam = {\n  pos: camera.position.clone(),\n  look: CENTER.clone(),\n  mode: 'orbit',\n  orbitAngle: 0.6,\n};\n\n// late-bound hooks set by main.js (avoids circular imports)\nexport const bus = { finishEvent: null };\n\nexport function setSlowmo(scale, realSeconds) {\n  game.tsTarget = scale;\n  game.tsTimer = realSeconds;\n}\n\n// Sensible-Soccer top-down follow camera: nearly straight overhead, trailing\n// just behind the athlete along their direction of travel so they always run\n// \"up the screen\". Pass dx/dz (path tangent) for lap events; defaults to +X.\nexport function followCam(px, pz, opt = {}) {\n  const h = opt.h != null ? opt.h : 70;\n  const back = opt.back != null ? opt.back : 13;\n  const lead = opt.lead != null ? opt.lead : 5;\n  const dx = opt.dx != null ? opt.dx : 1;\n  const dz = opt.dz != null ? opt.dz : 0;\n  return {\n    pos: new THREE.Vector3(px - dx * back, h, pz - dz * back),\n    look: new THREE.Vector3(px + dx * lead, 0, pz + dz * lead),\n  };\n}\n\nexport { clamp };\n"},{"name":"fx.js","content":"// fx.js — instanced particle system (confetti, sand, dirt)\nimport { THREE } from './lib.js';\nimport { scene } from './core.js';\n\nexport const CONFETTI = ['#ff4b3a', '#ffb020', '#4aa3ff', '#7ce34a', '#f5f1e6', '#c084fc'];\nexport const SAND = ['#e6c98c', '#dfc083', '#d4b478'];\nexport const DIRT = ['#5fae3f', '#4b9231', '#7a5c3a'];\nexport const DUST = ['#e5a58c', '#d9927b', '#caa08e'];\n\nconst P_MAX = 800;\nconst pGeo = new THREE.BoxGeometry(0.22, 0.22, 0.22);\nconst pMesh = new THREE.InstancedMesh(pGeo, new THREE.MeshStandardMaterial({ roughness: 0.8 }), P_MAX);\npMesh.frustumCulled = false;\nscene.add(pMesh);\nconst parts = [];\nconst pDummy = new THREE.Object3D();\nconst pColor = new THREE.Color();\n\nexport function spawnParticles(pos, colors, n, opt = {}) {\n  for (let i = 0; i < n; i++) {\n    if (parts.length >= P_MAX) parts.shift();\n    parts.push({\n      x: pos.x + (Math.random() - 0.5) * (opt.spread || 1),\n      y: pos.y + Math.random() * 0.5,\n      z: pos.z + (Math.random() - 0.5) * (opt.spread || 1),\n      vx: (Math.random() - 0.5) * (opt.vel || 5),\n      vy: (opt.up != null ? opt.up : 4) * (0.4 + Math.random() * 0.8),\n      vz: (Math.random() - 0.5) * (opt.vel || 5),\n      g: opt.g != null ? opt.g : 14,\n      life: (opt.life || 1) * (0.6 + Math.random() * 0.7),\n      rot: Math.random() * Math.PI,\n      rv: (Math.random() - 0.5) * 8,\n      s: (opt.size || 1) * (0.6 + Math.random() * 0.8),\n      color: colors[Math.floor(Math.random() * colors.length)],\n    });\n  }\n}\n\nexport function updateParticles(dt) {\n  for (let i = parts.length - 1; i >= 0; i--) {\n    const p = parts[i];\n    p.life -= dt;\n    if (p.life <= 0 || p.y < -1) { parts.splice(i, 1); continue; }\n    p.vy -= p.g * dt;\n    p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;\n    p.rot += p.rv * dt;\n  }\n  for (let i = 0; i < P_MAX; i++) {\n    if (i < parts.length) {\n      const p = parts[i];\n      pDummy.position.set(p.x, p.y, p.z);\n      pDummy.rotation.set(p.rot, p.rot * 0.7, 0);\n      pDummy.scale.setScalar(p.s);\n      pDummy.updateMatrix();\n      pMesh.setMatrixAt(i, pDummy.matrix);\n      pMesh.setColorAt(i, pColor.set(p.color));\n    } else {\n      pDummy.position.set(0, -50, 0);\n      pDummy.scale.setScalar(0.001);\n      pDummy.updateMatrix();\n      pMesh.setMatrixAt(i, pDummy.matrix);\n    }\n  }\n  pMesh.instanceMatrix.needsUpdate = true;\n  if (pMesh.instanceColor) pMesh.instanceColor.needsUpdate = true;\n}\n"},{"name":"gamepad.js","content":"// gamepad.js — poll pads each frame and translate button edges into the same\n// synthetic key events the touch controls use. D-pad ◀ ▶ (or the X/B face\n// buttons) mash, A = SPACE, Start = ENTER, stick also steers menus.\nconst MAP = [\n  [14, 'ArrowLeft'], [15, 'ArrowRight'],  // d-pad\n  [2, 'ArrowLeft'], [1, 'ArrowRight'],    // X / B — two-button face mashing\n  [0, 'Space'],                           // A\n  [9, 'Enter'], [8, 'Enter'],             // start / select\n  [12, 'ArrowUp'], [13, 'ArrowDown'],     // d-pad up/down (menus)\n];\n\nconst state = new Map();\nlet announced = false;\n\nconst fire = (code, type) =>\n  window.dispatchEvent(new KeyboardEvent(type, { code, bubbles: true }));\n\nexport function pollGamepads(onConnect) {\n  const pads = navigator.getGamepads ? navigator.getGamepads() : [];\n  for (const pad of pads) {\n    if (!pad || !pad.connected) continue;\n    if (!announced) { announced = true; if (onConnect) onConnect(pad.id); }\n    let st = state.get(pad.index);\n    if (!st) { st = {}; state.set(pad.index, st); }\n    for (const [btn, code] of MAP) {\n      const k = btn + ':' + code;\n      const down = !!(pad.buttons[btn] && pad.buttons[btn].pressed);\n      if (down !== !!st[k]) {\n        st[k] = down;\n        fire(code, down ? 'keydown' : 'keyup');\n      }\n    }\n    // left stick as ◀ ▶ with a dead zone\n    const ax = pad.axes[0] || 0;\n    const dir = ax < -0.55 ? 'ArrowLeft' : ax > 0.55 ? 'ArrowRight' : null;\n    if (st._axis !== dir) {\n      if (st._axis) fire(st._axis, 'keyup');\n      if (dir) fire(dir, 'keydown');\n      st._axis = dir;\n    }\n  }\n}\n"},{"name":"hud.js","content":"// hud.js — DOM overlay helpers\nconst $ = (id) => document.getElementById(id);\nconst clamp01 = (v) => Math.max(0, Math.min(1, v));\n\nexport const hud = {\n  root: $('hud'), timer: $('timer'), eventNo: $('event-no'), eventName: $('event-name'),\n  attempt: $('attempt-chip'), msg: $('center-msg'), popup: $('popup'),\n  powerWrap: $('power-wrap'), powerFill: $('power-fill'), powerLabel: $('power-label'),\n  angleWrap: $('angle-wrap'), angleFill: $('angle-fill'), angleLabel: $('angle-label'),\n  card: $('card'), cardNo: $('card-no'), cardName: $('card-name'), cardDesc: $('card-desc'),\n  cardRecords: $('card-records'), cardQual: $('card-qual'),\n  replayBadge: $('replay-badge'), careerLine: $('career-line'), titleStandings: $('title-standings'),\n  nameEntry: $('name-entry'),\n  wheels: [$('wheel-0'), $('wheel-1'), $('wheel-2')],\n  eventSelect: $('event-select'), evGrid: $('ev-grid'), evCount: $('ev-count'), evStart: $('ev-start'),\n  results: $('results'), resultsTitle: $('results-title'), resultsTable: $('results-table'),\n  resultsKicker: $('results-kicker'), resultsHint: $('results-hint'),\n  title: $('title'),\n};\n\nexport function showMsg(text, cls = '') {\n  hud.msg.textContent = text;\n  hud.msg.className = '';\n  void hud.msg.offsetWidth;\n  hud.msg.className = 'pop ' + cls;\n}\n\nexport function showPopup(text, bad = false) {\n  hud.popup.textContent = text;\n  hud.popup.className = bad ? 'bad' : '';\n  void hud.popup.offsetWidth;\n  hud.popup.className = (bad ? 'bad ' : '') + 'rise';\n}\n\nexport function setPower(v01, label) {\n  hud.powerFill.style.width = (clamp01(v01) * 100).toFixed(1) + '%';\n  if (label && hud.powerLabel) hud.powerLabel.textContent = label;\n}\n\n// second gauge: angle for jumps/throws, stamina for distance races\nexport function setGauge(pct01, label, show) {\n  hud.angleWrap.classList.toggle('hidden', !show);\n  if (show) {\n    hud.angleFill.style.width = (clamp01(pct01) * 100).toFixed(1) + '%';\n    hud.angleLabel.textContent = label;\n  }\n}\n\nexport function showTimer(show) { hud.timer.classList.toggle('hidden', !show); }\nexport function setTimer(text) { hud.timer.textContent = text; }\n\nexport function setAttempt(n, total) {\n  if (n == null) { hud.attempt.classList.add('hidden'); return; }\n  hud.attempt.textContent = 'ATTEMPT ' + n + ' / ' + total;\n  hud.attempt.classList.remove('hidden');\n}\n"},{"name":"lib.js","content":"// lib.js — three.js + tiny shared helpers used by every module\nimport * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';\n\nexport { THREE };\n\nexport const BOX = new THREE.BoxGeometry(1, 1, 1);\n\nexport const clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\nexport function stdMat(color, opts = {}) {\n  return new THREE.MeshStandardMaterial(Object.assign({ color, roughness: 0.92, metalness: 0.0 }, opts));\n}\n\nexport function boxMesh(w, h, d, color, opts = {}) {\n  const m = new THREE.Mesh(BOX, stdMat(color, opts));\n  m.scale.set(w, h, d);\n  m.castShadow = true;\n  m.receiveShadow = true;\n  return m;\n}\n\n// Build one InstancedMesh from a list of {x,y,z,sx,sy,sz,color,ry}\nexport function buildTiles(list, { shadowCast = false } = {}) {\n  const mat = new THREE.MeshStandardMaterial({ roughness: 0.95, metalness: 0 });\n  const mesh = new THREE.InstancedMesh(BOX, mat, list.length);\n  const d = new THREE.Object3D();\n  const c = new THREE.Color();\n  list.forEach((t, i) => {\n    d.position.set(t.x, t.y, t.z);\n    d.scale.set(t.sx, t.sy, t.sz);\n    d.rotation.set(0, t.ry || 0, 0);\n    d.updateMatrix();\n    mesh.setMatrixAt(i, d.matrix);\n    mesh.setColorAt(i, c.set(t.color));\n  });\n  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;\n  mesh.castShadow = shadowCast;\n  mesh.receiveShadow = true;\n  return mesh;\n}\n"},{"name":"main.js","content":"// main.js — Super Voxel Olympics: game flow, input, camera, render loop\nimport { THREE, clamp } from './lib.js';\nimport {\n  renderer, scene, camera, game, cam, bus, stadium,\n  athletes, player, setSlowmo, clearProps, ROSTER, IS_TOUCH,\n} from './core.js';\nimport * as records from './records.js';\nimport { buildNPCs, updateNPCs } from './npcs.js';\nimport { CENTER } from './world.js';\nimport * as hud from './hud.js';\nimport { audio } from './audio.js';\nimport { spawnParticles, updateParticles, CONFETTI } from './fx.js';\nimport { sprint100, hurdles110 } from './events/races.js';\nimport { run200, run400, run800, relay } from './events/laps.js';\nimport { longJump, tripleJump, highJump } from './events/jumps.js';\nimport { javelin, hammer, discus, shotPut } from './events/throws.js';\nimport { poleVault } from './events/vault.js';\nimport { QUALS, QUAL_BONUS } from './events/common.js';\nimport { pollGamepads } from './gamepad.js';\nimport * as career from './career.js';\n\n// ---------------------------------------------------------------- post FX\n// depth of field on desktop only. The addons are loaded dynamically because\n// they import the bare specifier 'three' (resolved via the import map) —\n// which older iOS Safari doesn't support. Phones skip DOF anyway, so they\n// never touch the import map and render directly.\nlet renderFrame = () => renderer.render(scene, camera);\nlet bokeh = null;\nif (!IS_TOUCH) {\n  Promise.all([\n    import('three/addons/postprocessing/EffectComposer.js'),\n    import('three/addons/postprocessing/RenderPass.js'),\n    import('three/addons/postprocessing/BokehPass.js'),\n    import('three/addons/postprocessing/OutputPass.js'),\n  ]).then(([ec, rp, bp, op]) => {\n    const composer = new ec.EffectComposer(renderer);\n    composer.addPass(new rp.RenderPass(scene, camera));\n    bokeh = new bp.BokehPass(scene, camera, { focus: 70, aperture: 0.00012, maxblur: 0.0075 });\n    composer.addPass(bokeh);\n    composer.addPass(new op.OutputPass());\n    window.addEventListener('resize', () => composer.setSize(window.innerWidth, window.innerHeight));\n    renderFrame = () => composer.render();\n  }).catch(() => { /* no import-map support — plain rendering is fine */ });\n}\n\n// victory spotlight: fades in over the athlete during low celebration shots\n// and on the podium, while the stadium lights dim around them\nconst spot = new THREE.SpotLight(0xfff2cc, 0, 90, Math.PI / 6.5, 0.5, 2);\nscene.add(spot, spot.target);\nlet spotI = 0;\nconst PODIUM_ANCHOR = new THREE.Vector3(0, 1.5, 12);\ngame.fovKick = 0;\ngame.arrowT = 99;\n\n// \"that's you\" marker: your initials float behind your runner at the start\n// of track races (and after relay handoffs), then fade out\nconst markerCanvas = document.createElement('canvas');\nmarkerCanvas.width = 256;\nmarkerCanvas.height = 128;\nconst markerCtx = markerCanvas.getContext('2d');\nconst markerTex = new THREE.CanvasTexture(markerCanvas);\nmarkerTex.colorSpace = THREE.SRGBColorSpace;\nfunction drawMarker(text) {\n  markerCtx.clearRect(0, 0, 256, 128);\n  markerCtx.font = '900 82px \"Archivo Black\", sans-serif';\n  markerCtx.textAlign = 'center';\n  markerCtx.textBaseline = 'middle';\n  markerCtx.lineWidth = 16;\n  markerCtx.lineJoin = 'round';\n  markerCtx.strokeStyle = 'rgba(10, 16, 30, 0.92)';\n  markerCtx.strokeText(text, 128, 66);\n  markerCtx.fillStyle = '#ffd24a';\n  markerCtx.fillText(text, 128, 66);\n  markerTex.needsUpdate = true;\n}\ndrawMarker('YOU');\nconst marker = new THREE.Sprite(new THREE.SpriteMaterial({ map: markerTex, transparent: true }));\nmarker.scale.set(1.9, 0.95, 1);\nmarker.visible = false;\nscene.add(marker);\n\nconst EVENTS = [\n  sprint100, longJump, javelin, hurdles110, highJump,\n  run200, tripleJump, shotPut, poleVault, run400,\n  hammer, discus, relay, run800,\n];\n\nlet currentEvent = null;\nlet lineup = EVENTS.slice(); // the events you chose on the select screen\n\n// ---------------------------------------------------------------- replay\n// during play we record a lightweight trace of the player (and the event's\n// projectile, if it exposes one). When a mark lands on the record board the\n// last few seconds play back in slow motion before the results.\nconst trace = [];\nconst replay = { t: 0, end: 0, idx: 0, cb: null, prop: null, propMoved: false };\n\nfunction recordTrace() {\n  const rp = currentEvent && currentEvent.replayProp ? currentEvent.replayProp() : null;\n  const s = {\n    t: game.time,\n    px: player.group.position.x, py: player.group.position.y, pz: player.group.position.z,\n    ry: player.group.rotation.y,\n  };\n  if (rp) { s.ex = rp.position.x; s.ey = rp.position.y; s.ez = rp.position.z; }\n  trace.push(s);\n  if (trace.length > 900) trace.shift();\n}\n\nfunction startReplay(cb) {\n  if (trace.length < 10) { cb(); return; }\n  const endT = trace[trace.length - 1].t;\n  replay.t = Math.max(trace[0].t, endT - 5.2);\n  replay.end = endT;\n  replay.idx = 0;\n  replay.cb = cb;\n  replay.prop = currentEvent && currentEvent.replayProp ? currentEvent.replayProp() : null;\n  const first = trace.find((s) => s.t >= replay.t) || trace[0];\n  const last = trace[trace.length - 1];\n  replay.propMoved = !!(replay.prop && first.ex != null && last.ex != null &&\n    Math.hypot(last.ex - first.ex, last.ez - first.ez) > 4);\n  game.state = 'replay';\n  hud.hud.replayBadge.classList.remove('hidden');\n  audio.beep(520, 0.15, 'triangle', 0.14);\n}\n\nfunction updateReplay(rdt) {\n  replay.t += rdt * 0.35;\n  while (replay.idx < trace.length - 1 && trace[replay.idx + 1].t <= replay.t) replay.idx++;\n  const s = trace[replay.idx];\n  const nx = trace[Math.min(replay.idx + 1, trace.length - 1)];\n  player.group.position.set(s.px, s.py, s.pz);\n  player.group.rotation.y = s.ry;\n  const spd = Math.hypot(nx.px - s.px, nx.pz - s.pz) / Math.max(nx.t - s.t, 0.001);\n  player.update(rdt * 0.35, s.py > 0.12 ? 'air' : spd > 0.8 ? 'run' : 'idle', { speed: spd });\n  if (replay.prop && s.ex != null) replay.prop.position.set(s.ex, s.ey, s.ez);\n  // the state stays 'replay' until the callback's reveal flips it, so only\n  // fire the callback once\n  if (replay.t >= replay.end && replay.cb) {\n    hud.hud.replayBadge.classList.add('hidden');\n    const cb = replay.cb;\n    replay.cb = null;\n    cb();\n  }\n}\n\n// ---------------------------------------------------------------- camera\nfunction camDesired() {\n  if (game.state === 'replay' && trace.length) {\n    const s = trace[Math.min(replay.idx, trace.length - 1)];\n    const fx = replay.propMoved && s.ex != null ? s.ex : s.px;\n    const fy = replay.propMoved && s.ey != null ? s.ey : s.py;\n    const fz = replay.propMoved && s.ez != null ? s.ez : s.pz;\n    return {\n      pos: new THREE.Vector3(fx - 5, Math.max(fy, 1.5) + 3.4, fz + 9.5),\n      look: new THREE.Vector3(fx, Math.max(fy, 1) + 0.8, fz),\n    };\n  }\n  if (cam.mode === 'orbit') {\n    return {\n      pos: new THREE.Vector3(\n        CENTER.x + Math.cos(cam.orbitAngle) * 175,\n        150,\n        CENTER.z + Math.sin(cam.orbitAngle) * 140\n      ),\n      look: new THREE.Vector3(CENTER.x, 2, CENTER.z),\n    };\n  }\n  if (cam.mode === 'podium') {\n    const a = game.time * 0.15;\n    return {\n      pos: new THREE.Vector3(Math.sin(a) * 10, 22, 46),\n      look: new THREE.Vector3(0, 2, 12),\n    };\n  }\n  if (currentEvent && currentEvent.camTarget) return currentEvent.camTarget();\n  return { pos: cam.pos.clone(), look: cam.look.clone() };\n}\n\n// ---------------------------------------------------------------- initials\nconst ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';\nconst nameState = { slot: 0, letters: [0, 0, 0] };\n\nfunction renderWheels() {\n  hud.hud.wheels.forEach((w, i) => {\n    const li = nameState.letters[i];\n    w.querySelector('.prev').textContent = ALPHA[(li + 25) % 26];\n    w.querySelector('.cur').textContent = ALPHA[li];\n    w.querySelector('.next').textContent = ALPHA[(li + 1) % 26];\n    w.classList.toggle('active', i === nameState.slot);\n    w.classList.toggle('locked', i < nameState.slot);\n  });\n}\n\nfunction openNameEntry() {\n  hud.hud.title.classList.add('hidden');\n  hud.hud.nameEntry.classList.remove('hidden');\n  nameState.slot = 0;\n  game.state = 'name';\n  renderWheels();\n}\n\nfunction confirmName() {\n  const initials = nameState.letters.map((i) => ALPHA[i]).join('');\n  ROSTER[0].name = initials;\n  athletes[0].name = initials;\n  drawMarker(initials);\n  game.totals = {};\n  ROSTER.forEach((r) => { game.totals[r.name] = 0; });\n  hud.hud.nameEntry.classList.add('hidden');\n  audio.beep(880, 0.2, 'square', 0.18);\n  openEventSelect();\n}\n\n// ---------------------------------------------------------------- event select\nconst LINEUP_KEY = 'svo-lineup-v1';\nconst sel = { cursor: 0, on: EVENTS.map(() => true) };\ntry {\n  const savedIds = JSON.parse(localStorage.getItem(LINEUP_KEY) || 'null');\n  if (Array.isArray(savedIds) && savedIds.length) {\n    const ids = new Set(savedIds);\n    sel.on = EVENTS.map((e) => ids.has(e.id));\n    if (!sel.on.some(Boolean)) sel.on = EVENTS.map(() => true);\n  }\n} catch (e) { /* fresh default: everything on */ }\n\nconst evItems = EVENTS.map((e, i) => {\n  const el = document.createElement('div');\n  el.className = 'ev-item';\n  el.innerHTML = '<span class=\"tick\">&#10003;</span><span>' + e.name + '</span>';\n  el.addEventListener('click', () => {\n    if (game.state !== 'select') return;\n    audio.ensure();\n    sel.cursor = i;\n    sel.on[i] = !sel.on[i];\n    audio.tick();\n    refreshSelect();\n  });\n  hud.hud.evGrid.appendChild(el);\n  return el;\n});\nhud.hud.evStart.addEventListener('click', () => {\n  if (game.state === 'select') { audio.ensure(); confirmSelection(); }\n});\n\nfunction refreshSelect() {\n  evItems.forEach((el, i) => {\n    el.classList.toggle('on', sel.on[i]);\n    el.classList.toggle('cur', i === sel.cursor);\n  });\n  const n = sel.on.filter(Boolean).length;\n  hud.hud.evCount.textContent = n === 0\n    ? 'PICK AT LEAST ONE EVENT'\n    : n + ' OF ' + EVENTS.length + ' EVENTS SELECTED';\n}\n\nfunction openEventSelect() {\n  const cs = career.get(ROSTER[0].name);\n  hud.hud.careerLine.textContent = cs\n    ? career.fmtLine(ROSTER[0].name, cs)\n    : 'FIRST MEET OF THE SEASON — GOOD LUCK, ' + ROSTER[0].name + '!';\n  hud.hud.eventSelect.classList.remove('hidden');\n  game.state = 'select';\n  refreshSelect();\n}\n\nfunction confirmSelection() {\n  if (!sel.on.some(Boolean)) { refreshSelect(); return; }\n  lineup = EVENTS.filter((e, i) => sel.on[i]);\n  try { localStorage.setItem(LINEUP_KEY, JSON.stringify(lineup.map((e) => e.id))); } catch (e) { /* ok */ }\n  hud.hud.eventSelect.classList.add('hidden');\n  audio.beep(880, 0.2, 'square', 0.18);\n  game.eventIndex = 0;\n  showCard();\n}\n\nfunction selectKey(code) {\n  const N = EVENTS.length;\n  const move = (d) => {\n    sel.cursor = (sel.cursor + d + N) % N;\n    audio.tick();\n    refreshSelect();\n  };\n  if (code === 'ArrowLeft' || code === 'KeyA') move(-1);\n  else if (code === 'ArrowRight' || code === 'KeyD') move(1);\n  else if (code === 'ArrowUp' || code === 'KeyW') move(-2);\n  else if (code === 'ArrowDown' || code === 'KeyS') move(2);\n  else if (code === 'Space') {\n    sel.on[sel.cursor] = !sel.on[sel.cursor];\n    audio.beep(sel.on[sel.cursor] ? 660 : 330, 0.08, 'square', 0.12);\n    refreshSelect();\n  } else if (code === 'Enter') {\n    confirmSelection();\n  }\n}\n\nfunction nameKey(code) {\n  if (code === 'ArrowLeft' || code === 'KeyA') {\n    nameState.letters[nameState.slot] = (nameState.letters[nameState.slot] + 25) % 26;\n    audio.tick();\n    renderWheels();\n  } else if (code === 'ArrowRight' || code === 'KeyD') {\n    nameState.letters[nameState.slot] = (nameState.letters[nameState.slot] + 1) % 26;\n    audio.tick();\n    renderWheels();\n  } else if (code === 'Space' || code === 'Enter') {\n    audio.beep(660, 0.1, 'square', 0.14);\n    nameState.slot++;\n    if (nameState.slot >= 3) confirmName();\n    else renderWheels();\n  } else if (code === 'Backspace' && nameState.slot > 0) {\n    nameState.slot--;\n    renderWheels();\n  }\n}\n\n// ---------------------------------------------------------------- flow\nfunction showCard() {\n  const e = lineup[game.eventIndex];\n  currentEvent = e;\n  finishing = false;\n  trace.length = 0;\n  hud.hud.cardNo.textContent = 'EVENT ' + (game.eventIndex + 1) + ' OF ' + lineup.length;\n  hud.hud.cardName.textContent = e.name;\n  hud.hud.cardDesc.textContent = e.desc;\n  const q = QUALS[e.id];\n  hud.hud.cardQual.classList.toggle('hidden', q == null);\n  if (q != null) {\n    hud.hud.cardQual.textContent = 'QUALIFYING MARK: ' +\n      (records.isTimed(e.id) ? 'UNDER ' : 'OVER ') + records.fmtRecord(e.id, q) +\n      ' · +' + QUAL_BONUS + ' PTS';\n  }\n  hud.hud.cardRecords.innerHTML = records.getTop3(e.id).map((r, i) =>\n    '<div class=\"rec-row\">' +\n    '<span class=\"rp\">' + (i + 1) + '</span>' +\n    '<span class=\"rn\">' + r.n + '</span>' +\n    '<span class=\"rv\">' + records.fmtRecord(e.id, r.v) + '</span>' +\n    '</div>'\n  ).join('');\n  hud.hud.card.classList.remove('hidden');\n  hud.hud.results.classList.add('hidden');\n  hud.hud.root.classList.remove('hidden');\n  hud.hud.eventNo.textContent = 'EVENT ' + (game.eventIndex + 1) + ' / ' + lineup.length;\n  hud.hud.eventName.textContent = e.name;\n  stadium.scoreboard(e.name, 'EVENT ' + (game.eventIndex + 1) + ' OF ' + lineup.length);\n  e.enter();\n  cam.mode = 'orbit';\n  game.state = 'card';\n}\n\nfunction startCountdown() {\n  hud.hud.card.classList.add('hidden');\n  game.cd.t = 0;\n  game.cd.step = -1;\n  game.state = 'countdown';\n  cam.mode = 'follow';\n}\n\nfunction falseStart() {\n  hud.showMsg('FALSE START!', 'bad');\n  audio.beep(180, 0.4, 'sawtooth', 0.2);\n  game.cd.t = -1.4;\n  game.cd.step = -1;\n}\n\nfunction updateCountdown(rdt) {\n  game.cd.t += rdt;\n  const step = Math.floor(game.cd.t);\n  if (step > game.cd.step && step >= 0) {\n    game.cd.step = step;\n    if (step < 3) {\n      hud.showMsg(String(3 - step));\n      audio.beep(440, 0.12);\n    } else {\n      hud.showMsg('GO!', 'go');\n      audio.beep(880, 0.3, 'square', 0.2);\n      game.state = 'play';\n      game.fovKick = 1; // camera punch off the gun\n      currentEvent.go();\n    }\n  }\n}\n\n// during a world-record reveal the game stays in 'play' for ~2s, so an event's\n// update() can call finish() again every frame — only the first call counts\nlet finishing = false;\n\nfunction finishEvent(rows) {\n  if (finishing) return;\n  finishing = true;\n  for (const r of rows) game.totals[r.name] += r.pts;\n  const e = currentEvent;\n\n  // record check — only the player's mark goes on the boards\n  const pRow = rows.find((r) => r.player);\n  let recordRank = 0;\n  if (pRow && pRow.val > 0) {\n    recordRank = records.submit(e.id, ROSTER[0].name, pRow.val);\n  }\n\n  // qualifying bonus\n  const q = QUALS[e.id];\n  let qualified = null;\n  if (q != null && pRow) {\n    qualified = records.isTimed(e.id) ? (pRow.val > 0 && pRow.val <= q) : pRow.val >= q;\n    if (qualified) game.totals[pRow.name] += QUAL_BONUS;\n  }\n  if (recordRank > 0) {\n    const rankMsg = ['1ST IN THE WORLD!', '2ND IN THE WORLD!', '3RD IN THE WORLD!'][recordRank - 1];\n    hud.showMsg(rankMsg, 'go');\n    audio.fanfare();\n    game.excitement = 1;\n    if (recordRank === 1) {\n      audio.cheer();\n      setSlowmo(0.4, 1.0);\n    }\n  }\n\n  const reveal = () => {\n    hud.hud.resultsKicker.textContent = 'RESULTS — EVENT ' + (game.eventIndex + 1) + ' OF ' + lineup.length;\n    hud.hud.resultsTitle.textContent = e.name;\n    const qualStrip = qualified == null ? '' :\n      '<div class=\"qual-strip' + (qualified ? '' : ' missed') + '\">' +\n      (qualified\n        ? 'QUALIFIED — +' + QUAL_BONUS + ' BONUS PTS'\n        : 'MISSED THE QUALIFYING MARK (' + (records.isTimed(e.id) ? 'UNDER ' : 'OVER ') + records.fmtRecord(e.id, q) + ')') +\n      '</div>';\n    hud.hud.resultsTable.innerHTML = qualStrip + rows.map((r, i) =>\n      '<div class=\"res-row' + (r.player ? ' player' : '') + '\">' +\n      '<span class=\"res-pos\">' + (i + 1) + '</span>' +\n      '<span class=\"res-name\">' + r.name + '</span>' +\n      '<span class=\"res-mark\">' + r.mark + (r.player && recordRank === 1 ? ' &#127942;' : '') + '</span>' +\n      '<span class=\"res-pts\">+' + r.pts + '<span class=\"res-total\">' + game.totals[r.name] + '</span></span>' +\n      '</div>'\n    ).join('');\n    hud.hud.resultsHint.innerHTML = game.eventIndex + 1 < lineup.length\n      ? 'PRESS <b>ENTER</b> FOR NEXT EVENT'\n      : 'PRESS <b>ENTER</b> FOR MEDAL CEREMONY';\n    hud.hud.results.classList.remove('hidden');\n    hud.setAttempt(null);\n    hud.setGauge(0, '', false);\n    cam.mode = 'orbit';\n    game.state = 'results';\n  };\n  // record-board marks earn a slow-mo replay before the results\n  // (relay's runners live in props, not the traced player, so it skips)\n  if (recordRank > 0 && e.id !== 'relay' && trace.length > 10) {\n    setTimeout(() => startReplay(() => setTimeout(reveal, 300)), 1600);\n  } else if (recordRank > 0) {\n    setTimeout(reveal, 2100);\n  } else {\n    reveal();\n  }\n\n  const top = rows[0];\n  stadium.scoreboard(recordRank === 1 ? 'WORLD RECORD!' : e.name, top.name + '  ' + top.mark);\n}\nbus.finishEvent = finishEvent;\n\nfunction startPodium() {\n  hud.hud.results.classList.add('hidden');\n  clearProps();\n  game.state = 'podium';\n  cam.mode = 'podium';\n  stadium.podium.visible = true;\n  game.excitement = 1;\n  audio.cheer();\n\n  const standings = Object.entries(game.totals).sort((a, b) => b[1] - a[1]);\n  const top3 = standings.slice(0, 3);\n  athletes.forEach((a) => { a.group.visible = false; });\n  top3.forEach(([name], i) => {\n    const a = athletes.find((x) => x.name === name);\n    if (!a) return;\n    a.group.visible = true;\n    a.group.position.copy(stadium.podiumSpots[i]);\n    a.group.rotation.y = 0;\n  });\n\n  const medals = ['\\u{1F947}', '\\u{1F948}', '\\u{1F949}'];\n  const pName = ROSTER[0].name;\n  const placement = standings.findIndex(([n]) => n === pName) + 1;\n  const cs = career.recordMeet(pName, placement, game.totals[pName] || 0);\n  hud.hud.resultsKicker.textContent = 'FINAL STANDINGS';\n  hud.hud.resultsTitle.textContent = 'MEDAL CEREMONY';\n  hud.hud.resultsTable.innerHTML = standings.map(([name, pts], i) =>\n    '<div class=\"res-row' + (name === pName ? ' player' : '') + '\">' +\n    '<span class=\"res-pos\">' + (medals[i] || (i + 1)) + '</span>' +\n    '<span class=\"res-name\">' + name + '</span>' +\n    '<span class=\"res-mark\"></span>' +\n    '<span class=\"res-pts\">' + pts + ' PTS</span>' +\n    '</div>'\n  ).join('') +\n  '<div class=\"career-strip\">CAREER · ' + career.fmtLine(pName, cs) + '</div>';\n  hud.hud.resultsHint.innerHTML = 'PRESS <b>ENTER</b> TO PLAY AGAIN';\n  hud.hud.results.classList.remove('hidden');\n  const winner = standings[0][0];\n  stadium.scoreboard('CHAMPION', winner + '  ' + standings[0][1] + ' PTS');\n  hud.showMsg(winner === pName ? 'CHAMPION!' : winner + ' WINS', winner === pName ? 'go' : '');\n}\n\nfunction onEnter() {\n  if (game.state === 'title') {\n    openNameEntry();\n  } else if (game.state === 'card') {\n    startCountdown();\n  } else if (game.state === 'results') {\n    game.eventIndex++;\n    if (game.eventIndex < lineup.length) showCard();\n    else startPodium();\n  } else if (game.state === 'podium') {\n    location.reload();\n  }\n}\n\n// ---------------------------------------------------------------- input\nlet lastSide = null;\nlet spaceDown = false;\n\nfunction mash(side) {\n  if (side === lastSide) return false;\n  lastSide = side;\n  return true;\n}\n\nwindow.addEventListener('keydown', (e) => {\n  if (['ArrowLeft', 'ArrowRight', 'Space', 'ArrowUp', 'ArrowDown'].includes(e.code)) e.preventDefault();\n  audio.ensure();\n  if (e.repeat) return;\n\n  if (game.state === 'name') { nameKey(e.code); return; }\n  if (game.state === 'select') { selectKey(e.code); return; }\n  if (e.code === 'Enter') { onEnter(); return; }\n\n  const side = (e.code === 'ArrowLeft' || e.code === 'KeyA') ? 'L'\n    : (e.code === 'ArrowRight' || e.code === 'KeyD') ? 'R' : null;\n\n  if (game.state === 'countdown') {\n    if ((side || e.code === 'Space') && currentEvent.falseStart && game.cd.step < 3 && game.cd.t > -0.5) falseStart();\n    return;\n  }\n  if (game.state !== 'play' || !currentEvent) return;\n\n  if (side && mash(side) && currentEvent.onMash) currentEvent.onMash();\n  if (e.code === 'Space' && !spaceDown) {\n    spaceDown = true;\n    if (currentEvent.onSpace) currentEvent.onSpace(true);\n  }\n});\n\nwindow.addEventListener('keyup', (e) => {\n  if (e.code === 'Space') {\n    spaceDown = false;\n    if (game.state === 'play' && currentEvent && currentEvent.onSpace) currentEvent.onSpace(false);\n  }\n});\n\n// ---------------------------------------------------------------- touch\nif (IS_TOUCH) {\n  document.body.classList.add('touch');\n  document.getElementById('touch-ui').classList.remove('hidden');\n  const fire = (code, type) => window.dispatchEvent(new KeyboardEvent(type, { code, bubbles: true }));\n  const bindPad = (id, code) => {\n    const el = document.getElementById(id);\n    el.addEventListener('pointerdown', (e) => {\n      e.preventDefault();\n      audio.ensure(); // real user gesture — unlocks audio on iOS\n      fire(code, 'keydown');\n    });\n    el.addEventListener('pointerup', (e) => { e.preventDefault(); fire(code, 'keyup'); });\n    el.addEventListener('pointercancel', () => fire(code, 'keyup'));\n  };\n  bindPad('t-run-l', 'ArrowLeft');\n  bindPad('t-run-r', 'ArrowRight');\n  bindPad('t-action', 'Space');\n  // tapping anywhere else advances menus (title, cards, results, name lock)\n  window.addEventListener('pointerdown', (e) => {\n    if (e.target.closest && e.target.closest('#touch-ui')) return;\n    if (['title', 'card', 'results', 'name', 'podium'].includes(game.state)) {\n      audio.ensure();\n      fire('Enter', 'keydown');\n    }\n  });\n}\n\n// ---------------------------------------------------------------- idle staging\nathletes.forEach((a, i) => {\n  a.group.position.set(-14 + i * 4, 0, 14);\n  a.group.rotation.y = 0;\n});\nstadium.scoreboard('VOXEL GAMES', 'PRESS ENTER');\nbuildNPCs();\n\n// season standings on the title screen (only once careers exist)\nconst seasonRows = career.standings(3);\nif (seasonRows.length) {\n  hud.hud.titleStandings.classList.remove('hidden');\n  hud.hud.titleStandings.innerHTML =\n    '<span class=\"ts-k\">SEASON STANDINGS</span>' +\n    seasonRows.map(([n, s], i) =>\n      '<span class=\"ts-row\">' + (i + 1) + '. ' + n + ' — ' + s.seasonPts.toLocaleString() + ' PTS</span>'\n    ).join('');\n}\n\n// ---------------------------------------------------------------- main loop\nlet last = performance.now();\n\nfunction loop(now) {\n  const rdt = Math.min((now - last) / 1000, 0.05);\n  last = now;\n  game.time += rdt;\n\n  pollGamepads(() => hud.showPopup('\\u{1F3AE} GAMEPAD CONNECTED'));\n\n  if (game.tsTimer > 0) {\n    game.tsTimer -= rdt;\n    if (game.tsTimer <= 0) game.tsTarget = 1;\n  }\n  game.timeScale += (game.tsTarget - game.timeScale) * Math.min(1, rdt * 8);\n  const dt = rdt * game.timeScale;\n\n  game.excitement = Math.max(0.15, game.excitement - rdt * 0.18);\n  audio.setAmbience(game.excitement);\n  audio.ambient(rdt, game.excitement);\n\n  if (game.state === 'countdown') {\n    updateCountdown(rdt);\n    if (currentEvent) currentEvent.update(0);\n  } else if (game.state === 'play' && currentEvent) {\n    currentEvent.update(dt);\n    recordTrace();\n  } else if (game.state === 'replay') {\n    updateReplay(rdt);\n  } else if (game.state === 'title' || game.state === 'card' || game.state === 'results') {\n    cam.orbitAngle += rdt * 0.07;\n    athletes.forEach((a) => { if (a.group.visible) a.update(rdt, 'idle'); });\n  } else if (game.state === 'podium') {\n    athletes.forEach((a) => { if (a.group.visible) a.update(rdt, 'celebrate'); });\n    if (Math.random() < 0.3) {\n      spawnParticles(\n        new THREE.Vector3(-12 + Math.random() * 24, 20, 2 + Math.random() * 18),\n        CONFETTI, 3, { up: -1, vel: 3, g: 2.5, life: 6, size: 0.9 }\n      );\n    }\n    // fireworks over the stands\n    game._fw = (game._fw || 0) - rdt;\n    if (game._fw <= 0) {\n      game._fw = 0.7 + Math.random() * 0.9;\n      spawnParticles(\n        new THREE.Vector3(-50 + Math.random() * 100, 32 + Math.random() * 18, -30 + Math.random() * 55),\n        CONFETTI, 28, { up: 0, vel: 16, g: 4, life: 1.7, size: 0.85, spread: 0.5 }\n      );\n      audio.beep(160 + Math.random() * 120, 0.3, 'sine', 0.12);\n    }\n  }\n\n  const target = camDesired();\n  const k = 1 - Math.exp(-3.4 * rdt);\n  cam.pos.lerp(target.pos, k);\n  cam.look.lerp(target.look, k);\n  camera.position.copy(cam.pos);\n\n  // victory spotlight + stadium dim during low hero shots and the podium\n  const heroShot = target.pos.y < 12 || game.state === 'podium';\n  spotI += ((heroShot ? 1100 : 0) - spotI) * Math.min(1, rdt * 4);\n  spot.intensity = spotI;\n  if (spotI > 1) {\n    const anchor = game.state === 'podium' ? PODIUM_ANCHOR : cam.look;\n    spot.position.set(anchor.x + 5, anchor.y + 26, anchor.z + 7);\n    spot.target.position.copy(anchor);\n  }\n  const dim = Math.min(1, rdt * 3);\n  stadium.hemi.intensity += ((heroShot ? 0.5 : 0.95) - stadium.hemi.intensity) * dim;\n  stadium.sun.intensity += ((heroShot ? 1.0 : 1.7) - stadium.sun.intensity) * dim;\n\n  // FOV punch on the starting gun\n  game.fovKick *= Math.exp(-3.5 * rdt);\n  const fov = 30 + game.fovKick * 5;\n  if (Math.abs(fov - camera.fov) > 0.01) {\n    camera.fov = fov;\n    camera.updateProjectionMatrix();\n  }\n  if (game.shake > 0.01) {\n    game.shake *= Math.exp(-6 * rdt);\n    camera.position.x += (Math.random() - 0.5) * game.shake;\n    camera.position.y += (Math.random() - 0.5) * game.shake * 0.6;\n  }\n  camera.lookAt(cam.look);\n\n  // player marker arrow during track-race starts\n  if (game.state === 'countdown') game.arrowT = 0;\n  else if (game.state === 'play') game.arrowT += rdt;\n  const anchor = currentEvent && currentEvent.playerAnchor &&\n    (game.state === 'countdown' || game.state === 'play')\n    ? currentEvent.playerAnchor() : null;\n  const arrowVis = anchor ? clamp(1 - (game.arrowT - 2.6) / 0.7, 0, 1) : 0;\n  if (arrowVis > 0.01) {\n    // trail BEHIND the runner: with the top-down camera, anything overhead\n    // would cover the player on screen — behind keeps them fully visible\n    const adx = anchor.dx != null ? anchor.dx : 1;\n    const adz = anchor.dz != null ? anchor.dz : 0;\n    marker.position.set(\n      anchor.x - adx * 2.2,\n      anchor.y + 0.8 + Math.sin(game.time * 3.2) * 0.14,\n      anchor.z - adz * 2.2\n    );\n    marker.material.opacity = arrowVis;\n    marker.visible = true;\n  } else {\n    marker.visible = false;\n  }\n\n  stadium.tick(game.time, rdt, game.excitement);\n  updateNPCs(rdt);\n  updateParticles(dt);\n\n  if (bokeh) {\n    const focusTarget = camera.position.distanceTo(cam.look);\n    bokeh.uniforms.focus.value += (focusTarget - bokeh.uniforms.focus.value) * Math.min(1, rdt * 6);\n  }\n  renderFrame();\n  requestAnimationFrame(loop);\n}\nrequestAnimationFrame(loop);\n\n// debug handle\nwindow.__game = { game, EVENTS, athletes, cam, bus, get currentEvent() { return currentEvent; } };\n"},{"name":"npcs.js","content":"// npcs.js — ambient stadium staff with actual jobs, placed in zones no\n// event ever enters so they can never clip the competitors:\n//  - starter official inside the first bend, arm raised through the countdown\n//  - two finish-line judges on the outer apron at the line\n//  - kneeling photographers along the home-straight apron\n//  - athletes warming up on the east D-zone grass (jog laps + stretches)\nimport { Athlete } from './athlete.js';\nimport { scene, game } from './core.js';\n\nconst OFFICIAL = { kit: '#2b3a4e', shorts: '#e8e8e8', skin: '#e8b48c', hair: '#2b3a4e' };\nconst PRESS = { kit: '#3d4a41', shorts: '#22292a', skin: '#c98e5a', hair: '#1a1a1a' };\nconst TRACKSUIT_A = { kit: '#7c8aa0', shorts: '#7c8aa0', skin: '#8a5a33', hair: '#101010' };\nconst TRACKSUIT_B = { kit: '#a0637c', shorts: '#a0637c', skin: '#f0c8a0', hair: '#5b3a1e', hairStyle: 'pony' };\n\nconst npcs = [];\n\nfunction spawn(opts, x, z, ry, role, extra = {}) {\n  const a = new Athlete(opts);\n  a.group.position.set(x, 0, z);\n  a.group.rotation.y = ry;\n  scene.add(a.group);\n  npcs.push(Object.assign({ a, role, x, z }, extra));\n}\n\nexport function buildNPCs() {\n  // starter: inside the west bend, a few metres off the 100m start\n  spawn(OFFICIAL, -55, 25.5, 0.4, 'starter');\n  // finish judges on the outer apron, eyes on the line\n  spawn(OFFICIAL, 43, 41.8, Math.PI, 'judge');\n  spawn(OFFICIAL, 45.8, 41.8, Math.PI + 0.15, 'judge');\n  // photographers kneeling along the home straight apron\n  spawn(PRESS, 12, 41.9, Math.PI, 'photo');\n  spawn(PRESS, 20, 41.9, Math.PI - 0.1, 'photo');\n  spawn(PRESS, 28, 41.9, Math.PI + 0.1, 'photo');\n  // warm-up zone: east D-zone grass, clear of every landing sector\n  spawn(TRACKSUIT_A, 37, 18, Math.PI / 2, 'jogger', { jx0: 36, jx1: 44, jdir: 1, jz: 18 });\n  spawn(TRACKSUIT_B, 43, 20.5, -Math.PI / 2, 'jogger', { jx0: 36, jx1: 44, jdir: -1, jz: 20.5 });\n  spawn(TRACKSUIT_A, 40, 22.5, Math.PI, 'stretcher');\n}\n\nexport function updateNPCs(dt) {\n  for (const n of npcs) {\n    if (n.role === 'starter') {\n      // arm shoots up for the countdown, drops on the gun\n      n.a.update(dt, game.state === 'countdown' ? 'charge' : 'idle', { jav: true });\n    } else if (n.role === 'judge') {\n      n.a.update(dt, 'idle');\n    } else if (n.role === 'photo') {\n      n.a.update(dt, 'crouch'); // hunched over the lens\n    } else if (n.role === 'jogger') {\n      n.x += n.jdir * 3.2 * dt;\n      if (n.x > n.jx1) { n.x = n.jx1; n.jdir = -1; }\n      if (n.x < n.jx0) { n.x = n.jx0; n.jdir = 1; }\n      n.a.group.position.set(n.x, 0, n.jz);\n      n.a.group.rotation.y = n.jdir > 0 ? Math.PI / 2 : -Math.PI / 2;\n      n.a.update(dt, 'run', { speed: 3.2 });\n    } else if (n.role === 'stretcher') {\n      // stretch cycle: deep lunge, stand and shake out, repeat\n      const c = n.a._t % 6;\n      n.a.update(dt, c < 2.6 ? 'crouch' : c < 3.2 ? 'idle' : 'charge');\n    }\n  }\n}\n"},{"name":"props.js","content":"// props.js — event equipment, real-world sizes (1 unit = 1m)\nimport { THREE, boxMesh } from './lib.js';\n\nexport function makeHurdle() {\n  const g = new THREE.Group();\n  const inner = new THREE.Group();\n  const legL = boxMesh(0.08, 0.95, 0.35, '#e8e8e8', { roughness: 0.5 });\n  legL.position.set(-0.5, 0.475, 0);\n  const legR = legL.clone();\n  legR.position.x = 0.5;\n  const bar = boxMesh(1.15, 0.2, 0.08, '#ffffff', { roughness: 0.5 });\n  bar.position.y = 0.98;\n  const stripe = boxMesh(1.16, 0.08, 0.09, '#ff4b3a');\n  stripe.position.y = 1.03;\n  inner.add(legL, legR, bar, stripe);\n  g.add(inner);\n  g.userData.inner = inner;\n  return g;\n}\n\nexport function makeJavelin() {\n  const g = new THREE.Group();\n  const shaft = boxMesh(2.6, 0.06, 0.06, '#d9d9d9', { roughness: 0.35, metalness: 0.4 });\n  const tip = boxMesh(0.35, 0.09, 0.09, '#4a4f58', { roughness: 0.3, metalness: 0.5 });\n  tip.position.x = 1.4;\n  const grip = boxMesh(0.4, 0.085, 0.085, '#ff4b3a');\n  g.add(shaft, tip, grip);\n  return g;\n}\n\nexport function makeHammer() {\n  // ball + short voxel \"wire\" segments; group origin = the athlete's hands\n  const g = new THREE.Group();\n  const ball = boxMesh(0.35, 0.35, 0.35, '#3a3f47', { roughness: 0.4, metalness: 0.5 });\n  ball.position.x = 1.2;\n  const wire = boxMesh(1.2, 0.045, 0.045, '#c9cdd6', { roughness: 0.4, metalness: 0.6 });\n  wire.position.x = 0.6;\n  g.add(ball, wire);\n  g.userData.ball = ball;\n  return g;\n}\n\nexport function makeDiscus() {\n  const g = new THREE.Group();\n  const core = boxMesh(0.55, 0.1, 0.55, '#d9a03c', { roughness: 0.5, metalness: 0.3 });\n  const rim = boxMesh(0.62, 0.06, 0.62, '#8a6420', { roughness: 0.5 });\n  g.add(core, rim);\n  return g;\n}\n\nexport function makeShot() {\n  const g = new THREE.Group();\n  const ball = boxMesh(0.24, 0.24, 0.24, '#3a3f47', { roughness: 0.35, metalness: 0.6 });\n  g.add(ball);\n  return g;\n}\n\nexport function makeHighJumpKit() {\n  // origin = bar line; mat extends +x (the landing side)\n  const g = new THREE.Group();\n  const upL = boxMesh(0.14, 2.9, 0.14, '#e8e8e8', { roughness: 0.5 });\n  upL.position.set(0, 1.45, -2.7);\n  const upR = upL.clone();\n  upR.position.z = 2.7;\n  const baseL = boxMesh(0.6, 0.12, 0.6, '#4a4f58', { roughness: 0.5 });\n  baseL.position.set(0, 0.06, -2.7);\n  const baseR = baseL.clone();\n  baseR.position.z = 2.7;\n  const bar = boxMesh(0.09, 0.09, 5.2, '#ff4b3a', { roughness: 0.4 });\n  bar.position.y = 1.9;\n  const mat = boxMesh(4.6, 0.72, 6.6, '#2563eb', { roughness: 0.9 });\n  mat.position.set(3.0, 0.36, 0);\n  const matTop = boxMesh(4.6, 0.1, 6.6, '#60a5fa', { roughness: 0.9 });\n  matTop.position.set(3.0, 0.77, 0);\n  g.add(upL, upR, baseL, baseR, bar, mat, matTop);\n  g.userData.bar = bar;\n  return g;\n}\n\nexport function makeVaultPole() {\n  // origin = butt end, shaft along +x, so rotation.z pivots around the plant\n  const g = new THREE.Group();\n  const shaft = boxMesh(4.6, 0.07, 0.07, '#ffb020', { roughness: 0.45 });\n  shaft.position.x = 2.3;\n  g.add(shaft);\n  return g;\n}\n\nexport function makeVaultKit() {\n  // origin = bar line; plant box before it, tall mat behind\n  const g = new THREE.Group();\n  const upL = boxMesh(0.16, 6.6, 0.16, '#e8e8e8', { roughness: 0.5 });\n  upL.position.set(0, 3.3, -2.7);\n  const upR = upL.clone();\n  upR.position.z = 2.7;\n  const baseL = boxMesh(0.7, 0.14, 0.7, '#4a4f58', { roughness: 0.5 });\n  baseL.position.set(0, 0.07, -2.7);\n  const baseR = baseL.clone();\n  baseR.position.z = 2.7;\n  const bar = boxMesh(0.09, 0.09, 5.2, '#ff4b3a', { roughness: 0.4 });\n  bar.position.y = 4.4;\n  const box = boxMesh(0.9, 0.12, 0.8, '#2b2f38', { roughness: 0.8 });\n  box.position.set(-1.9, 0.06, 0);\n  const mat = boxMesh(5.4, 0.9, 7.4, '#2563eb', { roughness: 0.9 });\n  mat.position.set(3.3, 0.45, 0);\n  const matTop = boxMesh(5.4, 0.12, 7.4, '#60a5fa', { roughness: 0.9 });\n  matTop.position.set(3.3, 0.96, 0);\n  g.add(upL, upR, baseL, baseR, bar, box, mat, matTop);\n  g.userData.bar = bar;\n  return g;\n}\n\nexport function makeDistanceFlag(color = '#ffb020') {\n  const g = new THREE.Group();\n  const pole = boxMesh(0.12, 2.2, 0.12, '#f5f1e6');\n  pole.position.y = 1.1;\n  const fl = boxMesh(0.9, 0.6, 0.08, color);\n  fl.position.set(0.45, 1.9, 0);\n  g.add(pole, fl);\n  return g;\n}\n"},{"name":"records.js","content":"// records.js — persistent top-3 records per event, Track & Field style.\n// Seeds sit right at the edge of what's humanly achievable, so breaking one\n// is a genuine feat. Stored in localStorage; falls back to memory.\nconst KEY = 'svo-records-v1';\n\n// lower-is-better events (times); everything else is distance\nconst TIMED = new Set(['sprint100', 'run200', 'hurdles110', 'run400', 'run800', 'relay']);\n\nconst SEEDS = {\n  sprint100:  [{ n: 'KON', v: 9.85 },  { n: 'AMI', v: 10.10 }, { n: 'T&F', v: 10.35 }],\n  run200:     [{ n: 'KON', v: 19.25 }, { n: 'AMI', v: 19.90 }, { n: 'T&F', v: 20.60 }],\n  hurdles110: [{ n: 'KON', v: 12.20 }, { n: 'AMI', v: 12.80 }, { n: 'T&F', v: 13.40 }],\n  run400:     [{ n: 'KON', v: 47.50 }, { n: 'AMI', v: 49.00 }, { n: 'T&F', v: 50.50 }],\n  run800:     [{ n: 'KON', v: 99.50 }, { n: 'AMI', v: 103.0 }, { n: 'T&F', v: 106.5 }],\n  relay:      [{ n: 'KON', v: 37.50 }, { n: 'AMI', v: 39.00 }, { n: 'T&F', v: 40.50 }],\n  longJump:   [{ n: 'KON', v: 8.55 },  { n: 'AMI', v: 8.25 },  { n: 'T&F', v: 7.95 }],\n  tripleJump: [{ n: 'KON', v: 17.40 }, { n: 'AMI', v: 16.60 }, { n: 'T&F', v: 15.90 }],\n  javelin:    [{ n: 'KON', v: 56.50 }, { n: 'AMI', v: 53.50 }, { n: 'T&F', v: 50.50 }],\n  hammer:     [{ n: 'KON', v: 69.50 }, { n: 'AMI', v: 66.00 }, { n: 'T&F', v: 62.00 }],\n  discus:     [{ n: 'KON', v: 55.50 }, { n: 'AMI', v: 52.00 }, { n: 'T&F', v: 48.00 }],\n  shotPut:    [{ n: 'KON', v: 20.80 }, { n: 'AMI', v: 19.40 }, { n: 'T&F', v: 18.00 }],\n  highJump:   [{ n: 'KON', v: 2.42 },  { n: 'AMI', v: 2.34 },  { n: 'T&F', v: 2.26 }],\n  poleVault:  [{ n: 'KON', v: 6.00 },  { n: 'AMI', v: 5.60 },  { n: 'T&F', v: 5.20 }],\n};\n\nlet table = null;\n\nfunction load() {\n  if (table) return table;\n  table = JSON.parse(JSON.stringify(SEEDS));\n  try {\n    const raw = localStorage.getItem(KEY);\n    if (raw) {\n      const saved = JSON.parse(raw);\n      for (const id in table) if (saved[id]) table[id] = saved[id];\n    }\n  } catch (e) { /* storage unavailable — session-only records */ }\n  return table;\n}\n\nfunction save() {\n  try { localStorage.setItem(KEY, JSON.stringify(table)); } catch (e) { /* ok */ }\n}\n\nexport function isTimed(id) { return TIMED.has(id); }\n\nexport function fmtRecord(id, v) {\n  return v.toFixed(2) + (isTimed(id) ? 's' : 'm');\n}\n\nexport function getTop3(id) {\n  return (load()[id] || []).slice(0, 3);\n}\n\n// returns the rank achieved (1..3) or 0 if the mark doesn't make the board\nexport function submit(id, initials, value) {\n  const t = load();\n  if (!t[id]) return 0;\n  const better = (a, b) => (isTimed(id) ? a < b : a > b);\n  const list = t[id].slice();\n  let rank = 0;\n  for (let i = 0; i < 3; i++) {\n    if (better(value, list[i].v)) { rank = i + 1; break; }\n  }\n  if (rank === 0) return 0;\n  list.splice(rank - 1, 0, { n: initials, v: value });\n  t[id] = list.slice(0, 3);\n  save();\n  return rank;\n}\n"},{"name":"world.js","content":"// world.js — the voxel stadium. 1 unit = 1 metre.\n// 400m oval: two 90m straights + semicircular bends, 8 lanes of 1.25m,\n// west chute for the 100m start, common finish line at x=43.\nimport { THREE, BOX, boxMesh, buildTiles } from './lib.js';\n\nexport const LANE_W = 1.25;\nconst R_IN = 30;          // track inner edge\nconst TRACK_W = 10;       // 8 lanes\nconst STRAIGHT = 45;      // half-length of each straight\nexport const TRACK = { rIn: R_IN, laneW: LANE_W, lanes: 8, straight: STRAIGHT, start: -57, finish: 43 };\nexport const CENTER = new THREE.Vector3(0, 0, 0);\nexport const FINISH_S = 88; // distance of the finish line from the path origin (x=-45 on home straight)\n\n// home straight is the SOUTH side (positive z, nearest the camera)\nexport function laneZ(i) { return R_IN + LANE_W * (i + 0.5); }\n\n// ring coordinate: distance from the oval spine\nfunction ovalDist(x, z) {\n  const ax = Math.abs(x);\n  if (ax <= STRAIGHT) return Math.abs(z);\n  return Math.hypot(ax - STRAIGHT, z);\n}\n\n// lap length for a lane offset o (metres from centre spine)\nexport function lapLength(o) { return 4 * STRAIGHT + 2 * Math.PI * o; }\n\n// point on the oval for lane offset o at arc distance s (counter-clockwise,\n// origin at x=-45 on the home straight). Returns {x, z, dx, dz}.\nexport function lanePath(o, s) {\n  const L = lapLength(o);\n  s = ((s % L) + L) % L;\n  if (s < 2 * STRAIGHT) return { x: -STRAIGHT + s, z: o, dx: 1, dz: 0 };\n  s -= 2 * STRAIGHT;\n  const arc = Math.PI * o;\n  if (s < arc) {\n    const p = s / o;\n    return { x: STRAIGHT + o * Math.sin(p), z: o * Math.cos(p), dx: Math.cos(p), dz: -Math.sin(p) };\n  }\n  s -= arc;\n  if (s < 2 * STRAIGHT) return { x: STRAIGHT - s, z: -o, dx: -1, dz: 0 };\n  s -= 2 * STRAIGHT;\n  const p = s / o;\n  return { x: -STRAIGHT - o * Math.sin(p), z: -o * Math.cos(p), dx: -Math.cos(p), dz: Math.sin(p) };\n}\n\n// heading (group.rotation.y) for a path direction — athlete model faces +X at PI/2\nexport function headingFor(dx, dz) { return Math.PI / 2 - Math.atan2(dz, dx); }\n\n// walk the oval at a given offset, step metres apart; fn(x, z, ry)\nfunction ovalPath(off, step, fn) {\n  for (let x = -STRAIGHT; x < STRAIGHT; x += step) {\n    fn(x + step / 2, off, 0);\n    fn(x + step / 2, -off, 0);\n  }\n  const n = Math.ceil(Math.PI * off / step);\n  for (let i = 0; i < n; i++) {\n    const th = -Math.PI / 2 + Math.PI * (i + 0.5) / n;\n    fn(STRAIGHT + off * Math.cos(th), off * Math.sin(th), Math.atan2(-Math.cos(th), -Math.sin(th)));\n    fn(-STRAIGHT - off * Math.cos(th), off * Math.sin(th), Math.atan2(-Math.cos(th), Math.sin(th)));\n  }\n}\n\n// ---------------------------------------------------------------- stadium\nexport function buildStadium(scene) {\n  const skyCol = new THREE.Color('#a5ddff');\n  scene.background = skyCol;\n  scene.fog = new THREE.Fog(skyCol, 340, 620);\n\n  const hemi = new THREE.HemisphereLight(0xcfe8ff, 0x5e7a4a, 0.95);\n  scene.add(hemi);\n  // cool rim fill from the south-west for shape definition on the voxels\n  const rim = new THREE.DirectionalLight(0x9fc4ff, 0.55);\n  rim.position.set(-70, 55, 85);\n  scene.add(rim);\n  const sun = new THREE.DirectionalLight(0xfff2dd, 1.7);\n  sun.position.set(90, 140, -60);\n  sun.target.position.set(0, 0, 0);\n  sun.castShadow = true;\n  sun.shadow.mapSize.set(2048, 2048);\n  // tighter bounds = crisper shadows from the same map\n  sun.shadow.camera.left = -112;\n  sun.shadow.camera.right = 112;\n  sun.shadow.camera.top = 112;\n  sun.shadow.camera.bottom = -112;\n  sun.shadow.camera.near = 20;\n  sun.shadow.camera.far = 420;\n  sun.shadow.normalBias = 0.4;\n  scene.add(sun, sun.target);\n\n  const ground = boxMesh(430, 1, 380, '#4a8438');\n  ground.position.y = -0.75;\n  ground.castShadow = false;\n  scene.add(ground);\n\n  // -------- oval track: voxel tiles with lane lines baked into the colours\n  const track = [];\n  const tCols = ['#d9553f', '#d05038', '#c74a33'];\n  const TS = 0.62;\n  // the bends reach |x| = STRAIGHT + R_IN + TRACK_W = 85, so sweep the full extent\n  for (let x = -86; x <= 86; x += TS) {\n    for (let z = -(R_IN + TRACK_W + 1); z <= R_IN + TRACK_W + 1; z += TS) {\n      const d = ovalDist(x, z);\n      const inOval = d >= R_IN - 0.05 && d <= R_IN + TRACK_W + 0.05;\n      const inChute = !inOval && z >= R_IN - 0.05 && z <= R_IN + TRACK_W + 0.05 && x >= -58.5 && x < -STRAIGHT;\n      if (!inOval && !inChute) continue;\n      track.push({ x, y: -0.16, z, sx: TS, sy: 0.5, sz: TS, color: tCols[Math.floor(Math.random() * 3)] });\n    }\n  }\n  scene.add(buildTiles(track));\n\n  // crisp lane lines that follow the curve: walk each lane boundary analytically\n  const lanes = [];\n  for (let j = 0; j <= 8; j++) {\n    const off = R_IN + j * LANE_W;\n    ovalPath(off, 0.5, (x, z, ry) => {\n      lanes.push({ x, y: 0.1, z, sx: 0.56, sy: 0.05, sz: 0.13, ry, color: '#f3efe2' });\n    });\n    // west chute extension (straight segment)\n    lanes.push({ x: -51.75, y: 0.1, z: off, sx: 13.5, sy: 0.05, sz: 0.13, color: '#f3efe2' });\n  }\n  scene.add(buildTiles(lanes));\n\n  // classic white/red kerb hugging the inside of lane 1\n  const kerb = [];\n  let ki = 0;\n  ovalPath(R_IN - 0.25, 0.6, (x, z, ry) => {\n    kerb.push({ x, y: 0.14, z, sx: 0.55, sy: 0.22, sz: 0.3, ry, color: ki++ % 2 ? '#e23b34' : '#f5f1e6' });\n  });\n  scene.add(buildTiles(kerb));\n\n  // start line (100m, west chute) + checkered common finish line + start blocks\n  const marks = [];\n  for (let i = 0; i < 8; i++) {\n    marks.push({ x: TRACK.start - 1.0, y: 0.2, z: laneZ(i), sx: 0.85, sy: 0.25, sz: 0.5, color: '#2a2f38' });\n  }\n  marks.push({ x: TRACK.start, y: 0.12, z: R_IN + TRACK_W / 2, sx: 0.35, sy: 0.06, sz: TRACK_W, color: '#ffffff' });\n  for (let z = R_IN; z < R_IN + TRACK_W - 0.01; z += 0.45) {\n    const k = Math.round(z / 0.45) % 2 === 0;\n    marks.push({ x: TRACK.finish, y: 0.12, z: z + 0.225, sx: 0.45, sy: 0.06, sz: 0.45, color: k ? '#14171c' : '#f3efe2' });\n    marks.push({ x: TRACK.finish + 0.45, y: 0.12, z: z + 0.225, sx: 0.45, sy: 0.06, sz: 0.45, color: k ? '#f3efe2' : '#14171c' });\n  }\n  scene.add(buildTiles(marks));\n\n  // -------- infield grass (mown checker) + grey apron ring outside the track\n  const grass = [];\n  for (let x = -90; x <= 90; x += 2) {\n    for (let z = -46; z <= 46; z += 2) {\n      const d = ovalDist(x, z);\n      if (d < R_IN - 0.2) {\n        const checker = (Math.floor(x / 5) + Math.floor(z / 5)) % 2 === 0;\n        let col = checker ? '#5fae3f' : '#54a137';\n        if (Math.random() < 0.03) col = '#4b9231';\n        grass.push({ x, y: -0.5, z, sx: 2, sy: 1, sz: 2, color: col });\n      }\n    }\n  }\n  // smooth analytic rings so the curved boundaries never look jagged:\n  // grass fringe inside the kerb, then a grey apron band outside the track\n  ovalPath(R_IN - 1.1, 1.0, (x, z, ry) => {\n    grass.push({ x, y: -0.48, z, sx: 1.05, sy: 1, sz: 2.3, ry, color: '#58a83b' });\n  });\n  ovalPath(R_IN + TRACK_W + 1.75, 1.1, (x, z, ry) => {\n    grass.push({ x, y: -0.45, z, sx: 1.15, sy: 1, sz: 3.5, ry, color: '#8b94a3' });\n  });\n  ovalPath(R_IN + TRACK_W + 3.85, 1.4, (x, z, ry) => {\n    grass.push({ x, y: -0.47, z, sx: 1.45, sy: 1, sz: 0.9, ry, color: '#7d8694' });\n  });\n  scene.add(buildTiles(grass));\n\n  // -------- long/triple jump: shared runway (z=24), boards, sand pit x 14..23\n  const lj = [];\n  for (let x = -25.5; x < 13.5; x += 0.65) {\n    for (let z = 23.2; z < 24.9; z += 0.65) {\n      lj.push({ x, y: -0.1, z, sx: 0.65, sy: 0.44, sz: 0.65, color: Math.random() < 0.5 ? '#8f9aa8' : '#98a3b1' });\n    }\n  }\n  // long jump board (x=13) and triple jump board (x=1)\n  lj.push({ x: 13, y: 0.14, z: 24, sx: 0.5, sy: 0.06, sz: 1.8, color: '#ffffff' });\n  lj.push({ x: 13.5, y: 0.14, z: 24, sx: 0.35, sy: 0.07, sz: 1.8, color: '#e23b34' });\n  lj.push({ x: 1, y: 0.14, z: 24, sx: 0.5, sy: 0.06, sz: 1.8, color: '#ffffff' });\n  lj.push({ x: 1.5, y: 0.14, z: 24, sx: 0.35, sy: 0.07, sz: 1.8, color: '#39d4c8' });\n  for (let x = 14; x < 23; x += 0.8) {\n    for (let z = 22.2; z < 26.2; z += 0.8) {\n      lj.push({ x, y: -0.06, z, sx: 0.8, sy: 0.5, sz: 0.8, color: Math.random() < 0.5 ? '#e6c98c' : '#dfc083' });\n    }\n  }\n  lj.push({ x: 18.5, y: 0.22, z: 21.9, sx: 9.4, sy: 0.36, sz: 0.18, color: '#f5f1e6' });\n  lj.push({ x: 18.5, y: 0.22, z: 26.3, sx: 9.4, sy: 0.36, sz: 0.18, color: '#f5f1e6' });\n  lj.push({ x: 23.2, y: 0.22, z: 24.1, sx: 0.18, sy: 0.36, sz: 4.6, color: '#f5f1e6' });\n  // runway edge lines\n  lj.push({ x: -6, y: 0.11, z: 23.15, sx: 39, sy: 0.05, sz: 0.12, color: '#f3efe2' });\n  lj.push({ x: -6, y: 0.11, z: 24.95, sx: 39, sy: 0.05, sz: 0.12, color: '#f3efe2' });\n  scene.add(buildTiles(lj));\n\n  // -------- javelin: runway on the infield centreline + sector + arcs\n  const jv = [];\n  for (let x = -43.5; x < -19.6; x += 0.65) {\n    for (let z = -1.0; z < 1.0; z += 0.65) {\n      jv.push({ x, y: -0.1, z, sx: 0.65, sy: 0.44, sz: 0.65, color: Math.random() < 0.5 ? '#8f9aa8' : '#98a3b1' });\n    }\n  }\n  jv.push({ x: -20, y: 0.14, z: 0, sx: 0.4, sy: 0.06, sz: 2.4, color: '#ffffff' });\n  // runway edge lines\n  jv.push({ x: -31.5, y: 0.11, z: -1.05, sx: 23.5, sy: 0.05, sz: 0.12, color: '#f3efe2' });\n  jv.push({ x: -31.5, y: 0.11, z: 1.05, sx: 23.5, sy: 0.05, sz: 0.12, color: '#f3efe2' });\n  for (let r = 3; r < 56; r += 0.75) {\n    for (const s of [-1, 1]) {\n      jv.push({ x: -20 + r * 0.967, y: 0.08, z: s * r * 0.255, sx: 0.28, sy: 0.26, sz: 0.28, color: '#f0ead8' });\n    }\n  }\n  for (const D of [30, 40, 50]) {\n    for (let a = -0.245; a <= 0.245; a += 0.022) {\n      jv.push({ x: -20 + D * Math.cos(a), y: 0.08, z: D * Math.sin(a), sx: 0.28, sy: 0.26, sz: 0.28, color: '#f0ead8' });\n    }\n  }\n  scene.add(buildTiles(jv));\n\n  // -------- throwing circles (hammer SW, discus NW of infield) + cages + sectors\n  const circles = [];\n  function throwingCircle(cx, cz, sectorA0, rayLen) {\n    for (let a = 0; a < Math.PI * 2; a += 0.14) {\n      circles.push({ x: cx + Math.cos(a) * 1.25, y: 0.1, z: cz + Math.sin(a) * 1.25, sx: 0.26, sy: 0.28, sz: 0.26, color: '#f5f1e6' });\n    }\n    for (let x = -0.9; x <= 0.9; x += 0.45) {\n      for (let z = -0.9; z <= 0.9; z += 0.45) {\n        if (Math.hypot(x, z) < 1.1) circles.push({ x: cx + x, y: -0.08, z: cz + z, sx: 0.45, sy: 0.4, sz: 0.45, color: '#9aa3b0' });\n      }\n    }\n    // sector rays (kept short + angled so they stay clear of the javelin fan)\n    for (let r = 2.2; r < rayLen; r += 0.75) {\n      for (const s of [-0.35, 0.35]) {\n        const a = sectorA0 + s;\n        circles.push({ x: cx + r * Math.cos(a), y: 0.06, z: cz + r * Math.sin(a), sx: 0.26, sy: 0.24, sz: 0.26, color: '#f0ead8' });\n      }\n    }\n    // C-shaped cage behind the circle\n    for (let a = sectorA0 + Math.PI * 0.62; a <= sectorA0 + Math.PI * 1.38; a += 0.22) {\n      const px = cx + Math.cos(a) * 3.4, pz = cz + Math.sin(a) * 3.4;\n      circles.push({ x: px, y: 2.1, z: pz, sx: 0.7, sy: 4.2, sz: 0.18, ry: -a, color: '#39404c' });\n    }\n  }\n  throwingCircle(-36, -14, -0.15, 34);   // hammer: aims east, hugging the south infield\n  throwingCircle(-36, 14, 0.1, 19);      // discus: short fan, north of the javelin\n  scene.add(buildTiles(circles, { shadowCast: true }));\n\n  // -------- ad boards ringing the track\n  const ads = [];\n  const adCols = ['#1f9e8e', '#f5f1e6', '#ff4b3a', '#2b6cb0', '#f5f1e6', '#ffb020'];\n  let adI = 0;\n  ovalPath(R_IN + TRACK_W + 1.6, 5.2, (x, z, ry) => {\n    ads.push({ x, y: 0.55, z, sx: 4.6, sy: 1.1, sz: 0.35, ry, color: adCols[adI++ % adCols.length] });\n  });\n  scene.add(buildTiles(ads, { shadowCast: true }));\n\n  // -------- oval grandstands + crowd\n  const tiers = [];\n  const crowdList = [];\n  const crowdCols = ['#ff5a5f', '#ffb020', '#4aa3ff', '#7ce34a', '#f5f1e6', '#c084fc', '#ff8a3c', '#39d4c8', '#e6e6e6', '#274690'];\n  const TIERS = 10, STEP_UP = 1.15, STEP_OUT = 1.9, BASE_OFF = 44;\n\n  for (let t = 0; t < TIERS; t++) {\n    const off = BASE_OFF + t * STEP_OUT;\n    const y = 0.9 + t * STEP_UP;\n    ovalPath(off, 3.2, (x, z, ry) => {\n      tiers.push({ x, y, z, sx: 3.0, sy: 1.15, sz: 1.9, ry, color: t % 2 === 0 ? '#b9bfc9' : '#aab0bb' });\n      const dirX = Math.cos(ry), dirZ = -Math.sin(ry);\n      // fan sections: blocks of supporters share colours around the bowl\n      const secCols = ['#ff4b3a', '#f5f1e6', '#274690', '#ffb020', '#1f9e8e', '#e6e6e6'];\n      const sec = Math.floor(((Math.atan2(z, x) + Math.PI) / (Math.PI * 2)) * 14);\n      for (const s of [-1.05, 0, 1.05]) {\n        if (Math.random() < 0.92) {\n          crowdList.push({\n            x: x + dirX * s + (Math.random() - 0.5) * 0.25,\n            y: y + 0.95,\n            z: z + dirZ * s + (Math.random() - 0.5) * 0.25,\n            color: Math.random() < 0.68\n              ? secCols[sec % secCols.length]\n              : crowdCols[Math.floor(Math.random() * crowdCols.length)],\n          });\n        }\n      }\n    });\n  }\n  ovalPath(BASE_OFF + (TIERS - 1) * STEP_OUT + 1.6, 3.2, (x, z, ry) => {\n    tiers.push({ x, y: 0.9 + (TIERS - 1) * STEP_UP + 1.3, z, sx: 3.2, sy: 1.6, sz: 0.6, ry, color: '#9aa1ad' });\n  });\n  scene.add(buildTiles(tiers, { shadowCast: true }));\n\n  // floating roof canopy: high and pushed outward so the follow camera\n  // always sees over its inner lip\n  const roofTiles = [];\n  const roofY = 0.9 + (TIERS - 1) * STEP_UP + 8.2;\n  ovalPath(BASE_OFF + (TIERS - 1) * STEP_OUT / 2 + 2.5, 3.3, (x, z, ry) => {\n    roofTiles.push({ x, y: roofY, z, sx: 3.3, sy: 0.35, sz: TIERS * STEP_OUT + 1, ry, color: '#e8ebf0' });\n  });\n  ovalPath(BASE_OFF + (TIERS - 1) * STEP_OUT + 2.6, 14, (x, z, ry) => {\n    roofTiles.push({ x, y: roofY / 2, z, sx: 0.8, sy: roofY, sz: 0.8, ry, color: '#9aa1ad' });\n  });\n  scene.add(buildTiles(roofTiles, { shadowCast: true }));\n\n  // outer concourse walkway + trees around the bowl\n  const outer = [];\n  ovalPath(66.5, 2.2, (x, z, ry) => {\n    outer.push({ x, y: -0.3, z, sx: 2.2, sy: 0.5, sz: 4.5, ry, color: '#a7adb8' });\n  });\n  scene.add(buildTiles(outer));\n  ovalPath(74, 15, (x, z) => {\n    if (Math.abs(x) > 76 && Math.abs(z) > 42) return;  // floodlight corners\n    if (x > 104 && Math.abs(z) < 14) return;           // cauldron\n    const g = new THREE.Group();\n    const trunk = boxMesh(0.55, 2.6, 0.55, '#6e4a2a');\n    trunk.position.y = 1.3;\n    const c1 = boxMesh(3.0, 2.2, 3.0, '#4c8f3a');\n    c1.position.y = 3.4;\n    const c2 = boxMesh(2.0, 1.5, 2.0, '#579f43');\n    c2.position.y = 4.9;\n    g.add(trunk, c1, c2);\n    g.position.set(x + (Math.random() - 0.5) * 5, 0, z + (Math.random() - 0.5) * 5);\n    scene.add(g);\n  });\n\n  // drifting voxel clouds\n  const clouds = [];\n  for (let i = 0; i < 8; i++) {\n    const g = new THREE.Group();\n    const n = 3 + Math.floor(Math.random() * 3);\n    for (let k = 0; k < n; k++) {\n      const puff = boxMesh(\n        7 + Math.random() * 9, 2.2 + Math.random() * 1.6, 4 + Math.random() * 4,\n        '#ffffff', {\n          roughness: 1,\n          emissive: new THREE.Color('#dfe9f5'), emissiveIntensity: 0.35,\n          transparent: true, opacity: 0.45, depthWrite: false, // see-through\n        }\n      );\n      puff.castShadow = false;\n      puff.receiveShadow = false;\n      puff.position.set((Math.random() - 0.5) * 14, (Math.random() - 0.5) * 2.4, (Math.random() - 0.5) * 8);\n      g.add(puff);\n    }\n    g.position.set(-220 + Math.random() * 440, 96 + Math.random() * 34, -160 + Math.random() * 320);\n    scene.add(g);\n    clouds.push(g);\n  }\n\n  // little spectator sprites: body + head + hair (three instanced layers)\n  const skinCols = ['#c98e5a', '#8a5a33', '#e8b48c', '#f0c8a0', '#6b4226'];\n  const hairCols = ['#20140e', '#3b2b1a', '#5b3a1e', '#101010', '#c9c9c9', '#8a5a33'];\n  const N = crowdList.length;\n  function crowdLayer() {\n    const m = new THREE.InstancedMesh(BOX, new THREE.MeshStandardMaterial({ roughness: 0.9 }), N);\n    m.castShadow = false;\n    m.receiveShadow = false;\n    // matrices are written each tick, so the auto bounding sphere is wrong —\n    // never cull, or the whole crowd vanishes when the camera swoops low\n    m.frustumCulled = false;\n    scene.add(m);\n    return m;\n  }\n  const crowdBody = crowdLayer();\n  const crowdHead = crowdLayer();\n  const crowdHair = crowdLayer();\n  const crowdArms = crowdLayer();\n  const crowdBase = new Float32Array(N * 3);\n  const crowdPhase = new Float32Array(N);\n  const crowdRot = new Float32Array(N);\n  {\n    const c = new THREE.Color();\n    crowdList.forEach((p, i) => {\n      crowdBase[i * 3] = p.x; crowdBase[i * 3 + 1] = p.y; crowdBase[i * 3 + 2] = p.z;\n      crowdPhase[i] = Math.random() * Math.PI * 2;\n      crowdRot[i] = (Math.random() - 0.5) * 0.8;\n      crowdBody.setColorAt(i, c.set(p.color));\n      const skin = skinCols[Math.floor(Math.random() * skinCols.length)];\n      crowdHead.setColorAt(i, c.set(skin));\n      crowdHair.setColorAt(i, c.set(hairCols[Math.floor(Math.random() * hairCols.length)]));\n      crowdArms.setColorAt(i, c.set(skin));\n    });\n    for (const m of [crowdBody, crowdHead, crowdHair, crowdArms]) {\n      if (m.instanceColor) m.instanceColor.needsUpdate = true;\n    }\n  }\n\n  // -------- flags on the rim\n  const flags = [];\n  const flagCols = ['#ff4b3a', '#ffb020', '#4aa3ff', '#7ce34a', '#f5f1e6', '#c084fc'];\n  const rimY = 0.9 + (TIERS - 1) * STEP_UP + 2.1;\n  let fi = 0;\n  ovalPath(BASE_OFF + (TIERS - 1) * STEP_OUT + 1.6, 13, (x, z, ry) => {\n    const g = new THREE.Group();\n    const pole = boxMesh(0.16, 3.4, 0.16, '#e8e8e8', { roughness: 0.5 });\n    pole.position.y = 1.7;\n    const flag = boxMesh(1.5, 0.9, 0.12, flagCols[fi % flagCols.length]);\n    flag.position.set(0.8, 2.9, 0);\n    g.add(pole, flag);\n    g.position.set(x, rimY, z);\n    g.userData.flag = flag;\n    scene.add(g);\n    flags.push(g);\n    fi++;\n  });\n\n  // -------- floodlight towers\n  for (const [tx, tz] of [[-84, -52], [-84, 52], [84, -52], [84, 52]]) {\n    const g = new THREE.Group();\n    const pole = boxMesh(1.4, 30, 1.4, '#8b93a0', { roughness: 0.6 });\n    pole.position.y = 15;\n    g.add(pole);\n    const head = new THREE.Group();\n    for (let r = 0; r < 3; r++) for (let c = 0; c < 4; c++) {\n      const lamp = boxMesh(1.4, 1.4, 0.5, '#ffffff', { emissive: new THREE.Color('#fff7d6'), emissiveIntensity: 1.6, roughness: 0.3 });\n      lamp.position.set((c - 1.5) * 1.6, r * 1.6, 0);\n      head.add(lamp);\n    }\n    const back = boxMesh(7, 5.2, 0.5, '#39404c');\n    back.position.set(0, 1.6, -0.5);\n    head.add(back);\n    head.position.y = 30.5;\n    head.rotation.y = Math.atan2(-tx, -tz);\n    head.rotation.x = 0.35;\n    g.add(head);\n    g.position.set(tx, 0, tz);\n    scene.add(g);\n  }\n\n  // -------- scoreboard above the north stand\n  const sbCanvas = document.createElement('canvas');\n  sbCanvas.width = 512; sbCanvas.height = 288;\n  const sbCtx = sbCanvas.getContext('2d');\n  const sbTex = new THREE.CanvasTexture(sbCanvas);\n  sbTex.colorSpace = THREE.SRGBColorSpace;\n  function drawScoreboard(line1, line2) {\n    sbCtx.fillStyle = '#0c1220';\n    sbCtx.fillRect(0, 0, 512, 288);\n    sbCtx.strokeStyle = '#1f2c48';\n    sbCtx.lineWidth = 8;\n    sbCtx.strokeRect(8, 8, 496, 272);\n    sbCtx.fillStyle = '#ffb020';\n    sbCtx.font = '900 44px \"Archivo Black\", sans-serif';\n    sbCtx.textAlign = 'center';\n    sbCtx.fillText(line1, 256, 120);\n    sbCtx.fillStyle = '#e8eefc';\n    sbCtx.font = '700 30px \"Space Grotesk\", sans-serif';\n    sbCtx.fillText(line2, 256, 190);\n    sbTex.needsUpdate = true;\n  }\n  drawScoreboard('VOXEL GAMES', 'WELCOME');\n  const sbGroup = new THREE.Group();\n  const sbFrame = boxMesh(20, 11, 1.4, '#232a36', { roughness: 0.5 });\n  const sbScreen = new THREE.Mesh(new THREE.PlaneGeometry(18.5, 9.8), new THREE.MeshBasicMaterial({ map: sbTex }));\n  sbScreen.position.z = 0.75;\n  sbGroup.add(sbFrame, sbScreen);\n  const sLegL = boxMesh(0.9, 9, 0.9, '#5a6270'); sLegL.position.set(-7, -9.5, 0);\n  const sLegR = boxMesh(0.9, 9, 0.9, '#5a6270'); sLegR.position.set(7, -9.5, 0);\n  sbGroup.add(sLegL, sLegR);\n  sbGroup.position.set(0, rimY + 9, -(BASE_OFF + (TIERS - 1) * STEP_OUT + 4));\n  scene.add(sbGroup);\n\n  // -------- olympic cauldron behind the east stand\n  const cauldron = new THREE.Group();\n  const cPole = boxMesh(2.4, 20, 2.4, '#c9cdd6', { roughness: 0.4, metalness: 0.3 });\n  cPole.position.y = 10;\n  const bowl = boxMesh(5.4, 1.8, 5.4, '#8d93a0', { roughness: 0.4, metalness: 0.4 });\n  bowl.position.y = 21;\n  cauldron.add(cPole, bowl);\n  const flameMat = new THREE.MeshStandardMaterial({ roughness: 0.6, emissive: new THREE.Color('#ff7b1f'), emissiveIntensity: 1.4 });\n  const flame = new THREE.InstancedMesh(BOX, flameMat, 14);\n  const flameCols = ['#ffdd55', '#ffab3d', '#ff7b1f', '#ff5722'];\n  {\n    const c = new THREE.Color();\n    for (let i = 0; i < 14; i++) flame.setColorAt(i, c.set(flameCols[i % flameCols.length]));\n    if (flame.instanceColor) flame.instanceColor.needsUpdate = true;\n  }\n  flame.position.y = 22.5;\n  flame.frustumCulled = false; // matrices set per-tick, same culling trap\n  cauldron.add(flame);\n  cauldron.position.set(112, 0, 0);\n  scene.add(cauldron);\n\n  // -------- podium (hidden until the ceremony)\n  const podium = new THREE.Group();\n  const p1 = boxMesh(2.2, 1.1, 2.2, '#f5d24a', { roughness: 0.5 }); p1.position.set(0, 0.55, 0);\n  const p2 = boxMesh(2.2, 0.75, 2.2, '#cfd8e6', { roughness: 0.5 }); p2.position.set(-2.3, 0.375, 0);\n  const p3 = boxMesh(2.2, 0.5, 2.2, '#e0925a', { roughness: 0.5 }); p3.position.set(2.3, 0.25, 0);\n  podium.add(p1, p2, p3);\n  podium.position.set(0, 0, 12);\n  podium.visible = false;\n  scene.add(podium);\n  const podiumSpots = [\n    new THREE.Vector3(0, 1.1, 12),\n    new THREE.Vector3(-2.3, 0.75, 12),\n    new THREE.Vector3(2.3, 0.5, 12),\n  ];\n\n  // ---------------- per-frame tick\n  const dummy = new THREE.Object3D();\n  let tickFrame = 0;\n  function tick(time, dt, excitement) {\n    // clouds drift every frame (cheap)\n    for (const cl of clouds) {\n      cl.position.x += dt * 1.4;\n      if (cl.position.x > 240) cl.position.x = -240;\n    }\n\n    // crowd animates at half rate — 4 layers x N instances,\n    // chibi proportions matching the athletes: big head, hair-dominant crown\n    tickFrame++;\n    if (tickFrame % 2 === 0) {\n      const amp = 0.12 + excitement * 0.45;\n      const freq = 4 + excitement * 4;\n      for (let i = 0; i < N; i++) {\n        const wave = Math.max(0, Math.sin(time * freq + crowdPhase[i]));\n        const bx = crowdBase[i * 3], by = crowdBase[i * 3 + 1] + wave * amp, bz = crowdBase[i * 3 + 2];\n        const ry = crowdRot[i] + Math.sin(time * 0.7 + crowdPhase[i]) * 0.25;\n        dummy.rotation.set(0, ry, 0);\n        // stocky body\n        dummy.position.set(bx, by, bz);\n        dummy.scale.set(0.64, 0.72, 0.46);\n        dummy.updateMatrix();\n        crowdBody.setMatrixAt(i, dummy.matrix);\n        // big head, straight on the shoulders\n        dummy.position.set(bx, by + 0.58, bz);\n        dummy.scale.set(0.56, 0.56, 0.56);\n        dummy.updateMatrix();\n        crowdHead.setMatrixAt(i, dummy.matrix);\n        // thick hair crown (the main read from above)\n        dummy.position.set(bx, by + 0.87, bz);\n        dummy.scale.set(0.62, 0.3, 0.62);\n        dummy.updateMatrix();\n        crowdHair.setMatrixAt(i, dummy.matrix);\n        // arms: resting bar across the body, thrown overhead on the wave\n        const lift = Math.max(0, wave - 0.35) * (0.9 + excitement * 0.5);\n        dummy.position.set(bx, by + 0.3 + lift, bz);\n        dummy.scale.set(1.0, 0.18, 0.24);\n        dummy.updateMatrix();\n        crowdArms.setMatrixAt(i, dummy.matrix);\n      }\n      crowdBody.instanceMatrix.needsUpdate = true;\n      crowdHead.instanceMatrix.needsUpdate = true;\n      crowdHair.instanceMatrix.needsUpdate = true;\n      crowdArms.instanceMatrix.needsUpdate = true;\n    }\n\n    for (let i = 0; i < flags.length; i++) {\n      const f = flags[i].userData.flag;\n      f.rotation.y = Math.sin(time * 2.2 + i * 1.3) * 0.35;\n      f.scale.y = 1 + Math.sin(time * 5 + i) * 0.06;\n    }\n\n    for (let i = 0; i < 14; i++) {\n      const a = time * (6 + (i % 4)) + i * 2.4;\n      dummy.position.set(Math.sin(a) * 1.0, 0.8 + Math.abs(Math.sin(a * 0.7)) * 1.8 + (i % 3) * 0.8, Math.cos(a * 0.9) * 1.0);\n      const s = 0.6 + Math.abs(Math.sin(a * 1.3)) * 1.3;\n      dummy.scale.set(s, s * 1.4, s);\n      dummy.rotation.set(0, a, 0);\n      dummy.updateMatrix();\n      flame.setMatrixAt(i, dummy.matrix);\n    }\n    flame.instanceMatrix.needsUpdate = true;\n  }\n\n  return { tick, scoreboard: drawScoreboard, podium, podiumSpots, hemi, sun };\n}\n"}],"folders":[{"folder":"events","files":[{"name":"common.js","content":"// events/common.js — formatting + scoring tables shared by all events\nimport { clamp } from '../lib.js';\n\nexport function fmtTime(t) { return t.toFixed(2) + 's'; }\nexport function fmtDist(d) { return d.toFixed(2) + 'm'; }\n\nconst lin = (v, worst, best) => Math.round(clamp((v - worst) / (best - worst), 0, 1) * 1000);\n\nexport const points = {\n  sprint100: (t) => lin(t, 12.8, 9.6),\n  run200: (t) => lin(t, 27.0, 19.2),\n  hurdles110: (t) => lin(t, 17.0, 12.4),\n  run400: (t) => lin(t, 72, 46),\n  run800: (t) => lin(t, 128, 92),\n  relay: (t) => lin(t, 50, 37.5),\n  longJump: (d) => lin(d, 5.2, 8.9),\n  tripleJump: (d) => lin(d, 11.0, 18.0),\n  javelin: (d) => lin(d, 28, 58),\n  hammer: (d) => lin(d, 38, 72),\n  discus: (d) => lin(d, 30, 58),\n  shotPut: (d) => lin(d, 10, 22),\n  highJump: (h) => lin(h, 1.70, 2.46),\n  poleVault: (h) => lin(h, 4.0, 6.2),\n};\n\n// qualifying marks — reach the mark (time under / distance over) for a bonus\nexport const QUAL_BONUS = 100;\nexport const QUALS = {\n  sprint100: 10.80, run200: 21.50, hurdles110: 14.50, run400: 52.0,\n  run800: 108.0, relay: 42.0,\n  longJump: 7.20, tripleJump: 14.50, highJump: 2.06, poleVault: 4.80,\n  javelin: 46.0, hammer: 58.0, discus: 46.0, shotPut: 16.0,\n};\n"},{"name":"jumps.js","content":"// events/jumps.js — long jump + triple jump (shared runway z=24, pit x 14..23)\nimport { THREE, clamp } from '../lib.js';\nimport { game, athletes, player, props, clearProps, followCam, bus } from '../core.js';\nimport * as hud from '../hud.js';\nimport { audio } from '../audio.js';\nimport { spawnParticles, SAND } from '../fx.js';\nimport { makeHighJumpKit } from '../props.js';\nimport { fmtDist, points } from './common.js';\n\nconst RUNWAY_Z = 24;\nconst PIT = [14, 23];\nconst G_JUMP = 15.5;\n\nfunction parkAI(xBase) {\n  athletes.filter((a) => !a.player).forEach((a, i) => {\n    a.group.position.set(xBase + i * 2.5, 0, 29);\n    a.group.rotation.y = Math.PI;\n  });\n}\n\n// ---------------------------------------------------------------- long jump\nexport const longJump = {\n  id: 'longJump', name: 'LONG JUMP', falseStart: false,\n  desc: 'Sprint down the runway with ◀ ▶. Hold SPACE to set your angle, release to leap — take off before the board or it is a foul. Two attempts.',\n  attempt: 0, best: 0, marks: [], phase: 'pre',\n  x: 0, v: 0, y: 0, vx: 0, vy: 0, angle: 0, angleDir: 1, foul: false,\n  landTimer: 0, aiRolls: [],\n\n  enter() {\n    clearProps();\n    this.attempt = 0;\n    this.best = 0;\n    this.marks = [];\n    athletes.forEach((a) => { a.group.visible = true; });\n    this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n      name: a.name, d: 7.15 + Math.random() * 1.25,\n    }));\n    this.resetAttempt();\n    hud.showTimer(false);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    parkAI(16);\n  },\n\n  resetAttempt() {\n    this.phase = this.attempt > 0 ? 'run' : 'pre';\n    this.x = -25; this.v = 0; this.y = 0; this.angle = 0; this.foul = false;\n    this.landTimer = 0;\n    player.group.position.set(this.x, 0, RUNWAY_Z);\n    player.group.rotation.y = Math.PI / 2;\n    hud.setAttempt(this.attempt + 1, 2);\n    hud.setGauge(0, '', false);\n    if (this.attempt > 0) hud.showMsg('ATTEMPT 2', 'go');\n  },\n\n  go() { this.phase = 'run'; game.excitement = 0.6; },\n\n  onMash() {\n    if (this.phase === 'run' || this.phase === 'charge') this.v = Math.min(11.5, this.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (down && this.phase === 'run') {\n      this.phase = 'charge';\n      this.angle = 24;\n      this.angleDir = 1;\n    } else if (!down && this.phase === 'charge') {\n      this.takeoff();\n    }\n  },\n\n  takeoff() {\n    this.foul = this.x > 13.6;\n    const rad = this.angle * Math.PI / 180;\n    const vj = this.v * 1.04;\n    this.vx = Math.cos(rad) * vj;\n    this.vy = Math.max(Math.sin(rad) * vj, 2);\n    this.phase = 'flight';\n    audio.whoosh();\n  },\n\n  land() {\n    this.phase = 'land';\n    this.landTimer = 0;\n    audio.thud();\n    const d = this.foul ? 0 : this.x - 13;\n    if (this.foul || d <= 0.2) {\n      this.marks.push(null);\n      hud.showMsg('FOUL', 'bad');\n      hud.showPopup(this.foul ? 'OVER THE BOARD' : 'TOO SHORT', true);\n    } else {\n      this.marks.push(d);\n      this.best = Math.max(this.best, d);\n      hud.showPopup(fmtDist(d) + '!');\n      game.excitement = Math.min(1, 0.5 + d / 12);\n      if (d > 7.6) audio.cheer();\n    }\n    if (this.x > PIT[0] && this.x < PIT[1]) {\n      spawnParticles(new THREE.Vector3(this.x, 0.3, RUNWAY_Z), SAND, 45, { up: 4, vel: 5, life: 0.9, g: 18 });\n    }\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') { player.update(dt, 'crouch'); return; }\n\n    if (this.phase === 'run' || this.phase === 'charge') {\n      this.v *= Math.exp(-1.45 * dt);\n      this.x += this.v * dt;\n      if (this.phase === 'charge') {\n        this.angle += this.angleDir * 75 * dt;\n        if (this.angle > 68) { this.angle = 68; this.angleDir = -1; }\n        if (this.angle < 22) { this.angle = 22; this.angleDir = 1; }\n        hud.setGauge(this.angle / 90, 'ANGLE ' + Math.round(this.angle) + '°', true);\n      }\n      if (this.x > 15) {\n        this.foul = true;\n        this.vx = Math.max(this.v, 2); this.vy = 2.5;\n        this.phase = 'flight';\n        hud.showMsg('FOUL', 'bad');\n      }\n      player.group.position.set(this.x, 0, RUNWAY_Z);\n      player.update(dt, this.phase === 'charge' ? 'charge' : this.v > 0.5 ? 'run' : 'idle', { speed: this.v });\n      hud.setPower(this.v / 11.5);\n      if (this.v > 1) {\n        this._stepT = (this._stepT || 0) + dt;\n        if (this._stepT > Math.max(0.16, 3.1 / (4 + this.v * 1.9))) { this._stepT = 0; audio.step(); }\n      }\n    } else if (this.phase === 'flight') {\n      this.vy -= G_JUMP * dt;\n      this.x += this.vx * dt;\n      this.y += this.vy * dt;\n      if (this.y <= 0) { this.y = 0; this.land(); }\n      player.group.position.set(this.x, this.y, RUNWAY_Z);\n      player.update(dt, 'air');\n    } else if (this.phase === 'land') {\n      player.update(dt, this.marks[this.marks.length - 1] ? 'celebrate' : 'dejected');\n      this.landTimer += dt;\n      if (this.landTimer > 2.4) {\n        this.attempt++;\n        if (this.attempt < 2) this.resetAttempt();\n        else this.finish();\n      }\n    }\n  },\n\n  finish() {\n    const rows = [\n      { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'FOUL', val: this.best, player: true, pts: this.best > 0 ? points.longJump(this.best) : 0 },\n      ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: points.longJump(r.d) })),\n    ].sort((a, b) => b.val - a.val);\n    bus.finishEvent(rows);\n  },\n\n  camTarget() {\n    if (this.phase === 'land') {\n      return {\n        pos: new THREE.Vector3(this.x + 7, 2.6, RUNWAY_Z + 2.5),\n        look: new THREE.Vector3(this.x, 2.3, RUNWAY_Z),\n      };\n    }\n    if (this.phase === 'flight') {\n      return followCam(clamp(this.x, 5, 26), RUNWAY_Z, { h: 42, back: 20, lead: 2 });\n    }\n    return followCam(clamp(this.x, -25, 20), RUNWAY_Z);\n  },\n};\n\n// ---------------------------------------------------------------- triple jump\n// Hop-step-jump: charge the first takeoff at the cyan board (x=1), then tap\n// SPACE just before each touchdown to keep your speed through the phases.\nexport const tripleJump = {\n  id: 'tripleJump', name: 'TRIPLE JUMP', falseStart: false,\n  desc: 'Run up with ◀ ▶, hold + release SPACE at the cyan board to HOP, then tap SPACE on each landing to chain the STEP and JUMP. Two attempts.',\n  attempt: 0, best: 0, marks: [], phase: 'pre',\n  x: 0, v: 0, y: 0, vx: 0, vy: 0, angle: 0, angleDir: 1, foul: false,\n  hopIdx: 0, tapAgo: 99, landTimer: 0, aiRolls: [],\n\n  enter() {\n    clearProps();\n    this.attempt = 0;\n    this.best = 0;\n    this.marks = [];\n    athletes.forEach((a) => { a.group.visible = true; });\n    this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n      name: a.name, d: 13.4 + Math.random() * 2.7,\n    }));\n    this.resetAttempt();\n    hud.showTimer(false);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    parkAI(16);\n  },\n\n  resetAttempt() {\n    this.phase = this.attempt > 0 ? 'run' : 'pre';\n    this.x = -25; this.v = 0; this.y = 0; this.angle = 0; this.foul = false;\n    this.hopIdx = 0; this.tapAgo = 99;\n    this.landTimer = 0;\n    player.group.position.set(this.x, 0, RUNWAY_Z);\n    player.group.rotation.y = Math.PI / 2;\n    hud.setAttempt(this.attempt + 1, 2);\n    hud.setGauge(0, '', false);\n    if (this.attempt > 0) hud.showMsg('ATTEMPT 2', 'go');\n  },\n\n  go() { this.phase = 'run'; game.excitement = 0.6; },\n\n  onMash() {\n    if (this.phase === 'run' || this.phase === 'charge') this.v = Math.min(11.2, this.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (down && this.phase === 'run') {\n      this.phase = 'charge';\n      this.angle = 18;\n      this.angleDir = 1;\n    } else if (!down && this.phase === 'charge') {\n      this.launch(0);\n    } else if (down && this.phase === 'flight') {\n      this.tapAgo = 0; // timing tap for the next phase\n    }\n  },\n\n  launch(idx) {\n    this.hopIdx = idx;\n    if (idx === 0) {\n      this.foul = this.x > 1.6;\n      audio.whoosh();\n    }\n    const phaseAngles = [Math.min(this.angle, 34), 25, 37];\n    const rad = phaseAngles[idx] * Math.PI / 180;\n    const vj = this.v * 1.02;\n    this.vx = Math.cos(rad) * vj;\n    this.vy = Math.max(Math.sin(rad) * vj, 2);\n    this.phase = 'flight';\n    hud.showPopup(['HOP!', 'STEP!', 'JUMP!'][idx]);\n  },\n\n  touchdown() {\n    if (this.hopIdx < 2) {\n      // timing: a fresh SPACE tap keeps speed through the phase change\n      const good = this.tapAgo < 0.35;\n      this.v *= good ? 0.88 : 0.7;\n      if (!good) hud.showPopup('LATE!', true);\n      this.tapAgo = 99;\n      audio.beep(good ? 700 : 260, 0.1, 'square', 0.14);\n      spawnParticles(new THREE.Vector3(this.x, 0.2, RUNWAY_Z), SAND, 8, { up: 2.5, vel: 3, life: 0.5, g: 18 });\n      this.launch(this.hopIdx + 1);\n    } else {\n      this.phase = 'land';\n      this.landTimer = 0;\n      audio.thud();\n      const d = this.foul ? 0 : this.x - 1;\n      if (this.foul || d <= 2) {\n        this.marks.push(null);\n        hud.showMsg('FOUL', 'bad');\n      } else {\n        this.marks.push(d);\n        this.best = Math.max(this.best, d);\n        hud.showPopup(fmtDist(d) + '!');\n        game.excitement = Math.min(1, 0.5 + d / 24);\n        if (d > 15) audio.cheer();\n      }\n      if (this.x > PIT[0] && this.x < PIT[1]) {\n        spawnParticles(new THREE.Vector3(this.x, 0.3, RUNWAY_Z), SAND, 45, { up: 4, vel: 5, life: 0.9, g: 18 });\n      }\n    }\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') { player.update(dt, 'crouch'); return; }\n    this.tapAgo += dt;\n\n    if (this.phase === 'run' || this.phase === 'charge') {\n      this.v *= Math.exp(-1.45 * dt);\n      this.x += this.v * dt;\n      if (this.phase === 'charge') {\n        this.angle += this.angleDir * 70 * dt;\n        if (this.angle > 40) { this.angle = 40; this.angleDir = -1; }\n        if (this.angle < 14) { this.angle = 14; this.angleDir = 1; }\n        hud.setGauge(this.angle / 90, 'ANGLE ' + Math.round(this.angle) + '°', true);\n      }\n      if (this.x > 3) {\n        this.foul = true;\n        this.vx = Math.max(this.v, 2); this.vy = 2.5;\n        this.hopIdx = 2;\n        this.phase = 'flight';\n        hud.showMsg('FOUL', 'bad');\n      }\n      player.group.position.set(this.x, 0, RUNWAY_Z);\n      player.update(dt, this.phase === 'charge' ? 'charge' : this.v > 0.5 ? 'run' : 'idle', { speed: this.v });\n      hud.setPower(this.v / 11.2);\n      if (this.v > 1) {\n        this._stepT = (this._stepT || 0) + dt;\n        if (this._stepT > Math.max(0.16, 3.1 / (4 + this.v * 1.9))) { this._stepT = 0; audio.step(); }\n      }\n    } else if (this.phase === 'flight') {\n      this.vy -= G_JUMP * dt;\n      this.x += this.vx * dt;\n      this.y += this.vy * dt;\n      if (this.y <= 0) { this.y = 0; this.touchdown(); }\n      player.group.position.set(this.x, this.y, RUNWAY_Z);\n      player.update(dt, 'air');\n    } else if (this.phase === 'land') {\n      player.update(dt, this.marks[this.marks.length - 1] ? 'celebrate' : 'dejected');\n      this.landTimer += dt;\n      if (this.landTimer > 2.4) {\n        this.attempt++;\n        if (this.attempt < 2) this.resetAttempt();\n        else this.finish();\n      }\n    }\n  },\n\n  finish() {\n    const rows = [\n      { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'FOUL', val: this.best, player: true, pts: this.best > 0 ? points.tripleJump(this.best) : 0 },\n      ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: points.tripleJump(r.d) })),\n    ].sort((a, b) => b.val - a.val);\n    bus.finishEvent(rows);\n  },\n\n  camTarget() {\n    if (this.phase === 'land') {\n      return {\n        pos: new THREE.Vector3(this.x + 7, 2.6, RUNWAY_Z + 2.5),\n        look: new THREE.Vector3(this.x, 2.3, RUNWAY_Z),\n      };\n    }\n    if (this.phase === 'flight') {\n      return followCam(clamp(this.x, -2, 26), RUNWAY_Z, { h: 42, back: 20, lead: 3 });\n    }\n    return followCam(clamp(this.x, -25, 20), RUNWAY_Z);\n  },\n};\n\n// ---------------------------------------------------------------- high jump\n// The bar keeps rising while you still have misses in hand. Speed comes from\n// the mash, height from a steep launch angle — but a steep leap barely\n// travels a metre forward, so take off tight to the bar.\nconst HJ_BAR_X = 8;\nconst HJ_G = 15.5;\n\nexport const highJump = {\n  id: 'highJump', name: 'HIGH JUMP', falseStart: false,\n  desc: 'The bar starts at 1.90m and rises 8cm with every clearance. Sprint with ◀ ▶, hold SPACE to steepen your angle, release close to the bar to leap. Three misses ends it.',\n  phase: 'pre', barH: 1.9, best: 0, misses: 0,\n  x: 0, v: 0, y: 0, vx: 0, vy: 0, angle: 0, angleDir: 1,\n  cleared: false, crossed: false, landTimer: 0, barFall: null,\n  aiRolls: [], kit: null, started: false,\n\n  enter() {\n    clearProps();\n    this.barH = 1.9;\n    this.best = 0;\n    this.misses = 0;\n    this.started = false;\n    athletes.forEach((a) => { a.group.visible = true; });\n    // AI clears a quantised bar height, same ladder as the player's\n    this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n      name: a.name, d: 1.9 + Math.floor(Math.random() * 6) * 0.08,\n    }));\n    this.kit = makeHighJumpKit();\n    this.kit.position.set(HJ_BAR_X, 0, RUNWAY_Z);\n    props.add(this.kit);\n    this.resetAttempt();\n    hud.showTimer(false);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    parkAI(-20);\n  },\n\n  resetAttempt() {\n    this.phase = this.started ? 'run' : 'pre';\n    this.x = -22; this.v = 0; this.y = 0; this.vx = 0; this.vy = 0;\n    this.angle = 0;\n    this.cleared = false; this.crossed = false;\n    this.landTimer = 0; this.barFall = null;\n    player.group.position.set(this.x, 0, RUNWAY_Z);\n    player.group.rotation.y = Math.PI / 2;\n    const bar = this.kit.userData.bar;\n    bar.position.set(0, this.barH, 0);\n    bar.rotation.set(0, 0, 0);\n    bar.visible = true;\n    hud.setGauge(0, '', false);\n    hud.hud.attempt.textContent = 'BAR ' + this.barH.toFixed(2) + 'M · MISSES ' + this.misses + ' / 3';\n    hud.hud.attempt.classList.remove('hidden');\n  },\n\n  go() { this.phase = 'run'; this.started = true; game.excitement = 0.6; },\n\n  onMash() {\n    if (this.phase === 'run' || this.phase === 'charge') this.v = Math.min(8.8, this.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (down && this.phase === 'run') {\n      this.phase = 'charge';\n      this.angle = 40;\n      this.angleDir = 1;\n    } else if (!down && this.phase === 'charge') {\n      this.takeoff(false);\n    }\n  },\n\n  takeoff(panic) {\n    const rad = (panic ? 28 : this.angle) * Math.PI / 180;\n    const vj = this.v * 1.06;\n    this.vx = Math.max(Math.cos(rad) * vj, 1.6);\n    this.vy = panic ? 3 : Math.max(Math.sin(rad) * vj, 2.5);\n    this.phase = 'flight';\n    audio.whoosh();\n  },\n\n  knockBar() {\n    this.barFall = 0;\n    audio.thud();\n    game.shake = 0.5;\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') { player.update(dt, 'crouch'); return; }\n\n    // the dislodged bar tumbles off the uprights\n    if (this.barFall != null && this.barFall < 1.3) {\n      this.barFall += dt;\n      const bar = this.kit.userData.bar;\n      bar.position.y = Math.max(0.86, this.barH - this.barFall * 2.2);\n      bar.position.x += dt * 1.6;\n      bar.rotation.x += dt * 2.6;\n    }\n\n    if (this.phase === 'run' || this.phase === 'charge') {\n      this.v *= Math.exp(-1.45 * dt);\n      this.x += this.v * dt;\n      if (this.phase === 'charge') {\n        this.angle += this.angleDir * 85 * dt;\n        if (this.angle > 82) { this.angle = 82; this.angleDir = -1; }\n        if (this.angle < 38) { this.angle = 38; this.angleDir = 1; }\n        hud.setGauge(this.angle / 90, 'ANGLE ' + Math.round(this.angle) + '°', true);\n      }\n      if (this.x > HJ_BAR_X - 1.1) {\n        this.takeoff(true);\n        hud.showPopup('TOO LATE!', true);\n      }\n      player.group.position.set(this.x, 0, RUNWAY_Z);\n      player.update(dt, this.phase === 'charge' ? 'charge' : this.v > 0.5 ? 'run' : 'idle', { speed: this.v });\n      hud.setPower(this.v / 8.8);\n      if (this.v > 1) {\n        this._stepT = (this._stepT || 0) + dt;\n        if (this._stepT > Math.max(0.16, 3.1 / (4 + this.v * 1.9))) { this._stepT = 0; audio.step(); }\n      }\n    } else if (this.phase === 'flight') {\n      const px = this.x;\n      this.vy -= HJ_G * dt;\n      this.x += this.vx * dt;\n      this.y += this.vy * dt;\n      if (!this.crossed && px < HJ_BAR_X && this.x >= HJ_BAR_X) {\n        this.crossed = true;\n        if (this.y >= this.barH - 0.25) this.cleared = true; // tucked legs buy 25cm\n        else this.knockBar();\n      }\n      const onMat = this.x > HJ_BAR_X + 0.8;\n      const floor = onMat ? 0.82 : 0;\n      if (this.y <= floor) { this.y = floor; this.land(); }\n      player.group.position.set(this.x, this.y, RUNWAY_Z);\n      player.update(dt, 'air');\n    } else if (this.phase === 'land') {\n      player.update(dt, this.cleared ? 'celebrate' : 'dejected');\n      this.landTimer += dt;\n      if (this.landTimer > 2.2) this.resolve();\n    } else if (this.phase === 'done') {\n      player.update(dt, this.cleared ? 'celebrate' : 'dejected');\n    }\n  },\n\n  land() {\n    this.phase = 'land';\n    this.landTimer = 0;\n    audio.thud();\n    if (!this.crossed) {\n      this.cleared = false;\n      hud.showPopup('TOO EARLY!', true);\n    }\n    if (this.cleared) {\n      hud.showPopup(this.barH.toFixed(2) + 'M CLEARED!');\n      game.excitement = Math.min(1, 0.4 + (this.barH - 1.8) / 0.7);\n      if (this.barH > 2.2) audio.cheer();\n    } else {\n      hud.showMsg('MISS', 'bad');\n    }\n    spawnParticles(new THREE.Vector3(this.x, this.y + 0.2, RUNWAY_Z), SAND, 18, { up: 2.5, vel: 3, life: 0.6, g: 16 });\n  },\n\n  resolve() {\n    if (this.cleared) {\n      this.best = this.barH;\n      this.barH = Math.round((this.barH + 0.08) * 100) / 100;\n    } else {\n      this.misses++;\n    }\n    if (this.misses >= 3 || this.barH > 2.62) { this.phase = 'done'; this.finish(); }\n    else this.resetAttempt();\n  },\n\n  finish() {\n    const rows = [\n      { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'NO MARK', val: this.best, player: true, pts: this.best > 0 ? points.highJump(this.best) : 0 },\n      ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: points.highJump(r.d) })),\n    ].sort((a, b) => b.val - a.val);\n    bus.finishEvent(rows);\n  },\n\n  camTarget() {\n    if (this.phase === 'flight' || this.phase === 'land') {\n      // low side-on shot so the height of the leap reads clearly\n      return {\n        pos: new THREE.Vector3(this.x - 3.5, Math.max(this.y, 1.4) + 2.6, RUNWAY_Z + 13),\n        look: new THREE.Vector3(this.x, Math.max(this.y, 1.2), RUNWAY_Z),\n      };\n    }\n    return followCam(clamp(this.x, -22, 9), RUNWAY_Z);\n  },\n};\n"},{"name":"laps.js","content":"// events/laps.js — full-oval races: 400m, 800m (stamina) + 4x100m relay\nimport { THREE, clamp, boxMesh } from '../lib.js';\nimport { game, athletes, props, clearProps, followCam, setSlowmo, bus, ROSTER } from '../core.js';\nimport { laneZ, lanePath, lapLength, headingFor, FINISH_S } from '../world.js';\nimport { Athlete } from '../athlete.js';\nimport * as hud from '../hud.js';\nimport { audio } from '../audio.js';\nimport { spawnParticles, CONFETTI, DUST } from '../fx.js';\nimport { fmtTime, points } from './common.js';\n\n// ---------------------------------------------------------------- 400 / 800\nfunction makeLapRace(cfg) {\n  return {\n    id: cfg.id, name: cfg.name, desc: cfg.desc, falseStart: true,\n    racers: [], clock: 0, phase: 'pre', endTimer: 0, D: 0,\n\n    enter() {\n      clearProps();\n      this.phase = 'pre';\n      this.clock = 0;\n      this.endTimer = 0;\n      this.D = cfg.laps * lapLength(laneZ(0));\n      athletes.forEach((a) => { a.group.visible = true; });\n      this.racers = athletes.map((a, i) => ({\n        a, lane: i, o: 0, p: 0, v: 0, st: 1,\n        finished: false, time: 0,\n        ai: a.player ? null : {\n          vmax: cfg.aiV[0] + Math.random() * (cfg.aiV[1] - cfg.aiV[0]),\n          kick: 1.05 + Math.random() * 0.08,\n        },\n      }));\n      const pi = this.racers.findIndex((r) => r.a.player);\n      const tmp = this.racers[pi].lane;\n      this.racers[pi].lane = this.racers[3].lane;\n      this.racers[3].lane = tmp;\n      for (const r of this.racers) {\n        r.o = laneZ(r.lane);\n        this.place(r, 0);\n      }\n      hud.showTimer(true);\n      hud.setPower(0, 'PACE — MASH ◀ ▶');\n      hud.setGauge(1, 'STAMINA', true);\n      hud.setAttempt(null);\n      if (cfg.laps > 1) {\n        hud.hud.attempt.textContent = 'LAP 1 / ' + cfg.laps;\n        hud.hud.attempt.classList.remove('hidden');\n      }\n    },\n\n    place(r, dt, mode, opt) {\n      const s = FINISH_S - this.D + r.p;\n      const pt = lanePath(r.o, s);\n      r.a.group.position.set(pt.x, 0, pt.z);\n      r.a.group.rotation.y = headingFor(pt.dx, pt.dz);\n      r.a.update(dt, mode || 'crouch', opt);\n      r._pt = pt;\n    },\n\n    go() {\n      this.phase = 'race';\n      audio.gun();\n      game.excitement = 0.7;\n    },\n\n    onMash() {\n      if (this.phase !== 'race') return;\n      const p = this.racers.find((r) => r.a.player);\n      if (p.finished) return;\n      const capEff = 5.5 + p.st * (cfg.vcap - 5.5);\n      p.v = Math.min(capEff, p.v + 1.35);\n    },\n\n    onSpace() {},\n\n    update(dt) {\n      if (this.phase === 'pre') {\n        for (const r of this.racers) this.place(r, dt, 'crouch');\n        return;\n      }\n      this.clock += dt;\n\n      for (const r of this.racers) {\n        if (r.ai) {\n          const remaining = this.D - r.p;\n          let vt = r.finished ? 2 : r.ai.vmax;\n          if (!r.finished && remaining < 90) vt *= r.ai.kick;\n          vt *= (1 - Math.exp(-(this.clock + 0.001) / 1.4));\n          r.v += (vt - r.v) * Math.min(1, dt * 2.2);\n        } else if (r.finished && !r.ai) {\n          r.v += (0 - r.v) * dt * 2.5;\n        } else if (!r.finished) {\n          r.v *= Math.exp(-0.85 * dt);\n          // stamina: only a flat-out pace burns, easing off recovers quickly\n          r.st = clamp(r.st - dt * Math.max(0, r.v - 8.0) * cfg.drain + (r.v < 7.2 ? dt * 0.045 : 0), 0, 1);\n          const capEff = 6.5 + r.st * (cfg.vcap - 6.5);\n          r.v = Math.min(r.v, capEff);\n        } else {\n          r.v += (2 - r.v) * dt * 2;\n        }\n\n        if (r.p < this.D + 10) r.p += r.v * dt;\n\n        if (!r.finished && r.p >= this.D) {\n          r.finished = true;\n          r.time = this.clock - (r.p - this.D) / Math.max(r.v, 0.1);\n          if (r.a.player) {\n            game.excitement = 1;\n            audio.cheer();\n            const pos = this.racers.filter((o) => o.finished && o !== r && o.time < r.time).length + 1;\n            hud.showMsg(['1ST!', '2ND', '3RD', '4TH'][pos - 1], pos === 1 ? 'go' : '');\n            setSlowmo(0.35, 0.8);\n            spawnParticles(new THREE.Vector3(r._pt.x, 1.5, r._pt.z), CONFETTI, 40, { up: 6, vel: 6, life: 1.4 });\n          }\n        }\n        const doneMode = r.st < 0.35 ? 'exhausted' : 'celebrate'; // spent runners pant\n        this.place(r, dt, r.v > 0.6 ? 'run' : (r.finished && !r.ai ? doneMode : 'idle'), { speed: r.v });\n\n        if (r.v > 7 && !r.finished && r._pt) {\n          r._dust = (r._dust || 0) + dt;\n          if (r._dust > 0.11) {\n            r._dust = 0;\n            spawnParticles(new THREE.Vector3(r._pt.x - r._pt.dx * 0.5, 0.12, r._pt.z - r._pt.dz * 0.5), DUST, 2, { up: 1.4, vel: 1.8, life: 0.5, g: 3, size: 0.55, spread: 0.4 });\n          }\n        }\n        if (!r.ai && r.v > 1 && !r.finished) {\n          r._stepT = (r._stepT || 0) + dt;\n          if (r._stepT > Math.max(0.16, 3.1 / (4 + r.v * 1.9))) {\n            r._stepT = 0;\n            audio.step();\n          }\n        }\n      }\n\n      const p = this.racers.find((r) => r.a.player);\n      hud.setPower(p.v / cfg.vcap);\n      hud.setGauge(p.st, 'STAMINA', true);\n      hud.setTimer((p.finished ? p.time : this.clock).toFixed(2));\n      if (cfg.laps > 1 && !p.finished) {\n        const lap = Math.min(cfg.laps, Math.floor(p.p / lapLength(laneZ(0))) + 1);\n        hud.hud.attempt.textContent = 'LAP ' + lap + ' / ' + cfg.laps;\n        if (lap === cfg.laps && !this._bell) { this._bell = true; hud.showMsg('BELL LAP!', 'go'); audio.beep(1200, 0.5, 'triangle', 0.2); }\n      }\n\n      if (p.finished) {\n        this.endTimer += dt;\n        const allDone = this.racers.every((r) => r.finished);\n        if ((allDone && this.endTimer > 3.2) || this.endTimer > 5.5) {\n          for (const r of this.racers) {\n            if (!r.finished) { r.finished = true; r.time = this.clock + (this.D - r.p) / Math.max(r.v, 1); }\n          }\n          const rows = this.racers\n            .map((r) => ({ name: r.a.name, mark: fmtTime(r.time), val: r.time, player: r.a.player, pts: cfg.pts(r.time) }))\n            .sort((a, b) => a.val - b.val);\n          this._bell = false;\n          bus.finishEvent(rows);\n        }\n      }\n    },\n\n    playerAnchor() {\n      const p = this.racers.find((r) => r.a.player);\n      if (p.finished) return null;\n      const pt = p._pt || lanePath(p.o, FINISH_S - this.D);\n      return { x: pt.x, y: 2.1, z: pt.z, dx: pt.dx, dz: pt.dz };\n    },\n\n    camTarget() {\n      const p = this.racers.find((r) => r.a.player);\n      const pt = p._pt || lanePath(p.o, FINISH_S - this.D);\n      if (p.finished && this.phase === 'race') {\n        const drift = Math.sin(game.time * 0.6) * 2.2;\n        return {\n          pos: new THREE.Vector3(pt.x + pt.dx * 7.5 - pt.dz * drift, 2.7, pt.z + pt.dz * 7.5 + pt.dx * drift),\n          look: new THREE.Vector3(pt.x, 2.4, pt.z),\n        };\n      }\n      return followCam(pt.x, pt.z, { dx: pt.dx, dz: pt.dz });\n    },\n  };\n}\n\nexport const run200 = makeLapRace({\n  id: 'run200', name: '200M',\n  desc: 'Half a lap — the final bend, then the home straight. Stamina barely bites at this distance, so empty the tank.',\n  laps: 0.5, vcap: 11.0, aiV: [9.3, 10.1], drain: 0.008, pts: points.run200,\n});\n\nexport const run400 = makeLapRace({\n  id: 'run400', name: '400M',\n  desc: 'One full lap in lanes. Mash ◀ ▶ but watch your STAMINA — burn it all on the back straight and you will crawl home.',\n  laps: 1, vcap: 10.6, aiV: [7.9, 8.7], drain: 0.013, pts: points.run400,\n});\n\nexport const run800 = makeLapRace({\n  id: 'run800', name: '800M',\n  desc: 'Two laps. Pace yourself — keep a rhythm, save a kick for the bell lap.',\n  laps: 2, vcap: 10.0, aiV: [7.0, 7.7], drain: 0.017, pts: points.run800,\n});\n\n// ---------------------------------------------------------------- 4 x 100m relay\nexport const relay = {\n  id: 'relay', name: '4 × 100M RELAY', falseStart: true,\n  desc: 'Four legs around the oval. Mash ◀ ▶ to sprint; press SPACE in the exchange zone to pass the baton — miss it and you fumble.',\n  teams: [], clock: 0, phase: 'pre', endTimer: 0, D: 0, legLen: 0,\n\n  enter() {\n    clearProps();\n    this.phase = 'pre';\n    this.clock = 0;\n    this.endTimer = 0;\n    this.D = lapLength(laneZ(0));\n    this.legLen = this.D / 4;\n    athletes.forEach((a) => { a.group.visible = false; });\n    this.teams = ROSTER.map((ros, i) => {\n      const runners = [];\n      for (let k = 0; k < 4; k++) {\n        const a = new Athlete({ ...ros, num: k + 1 });\n        props.add(a.group);\n        runners.push(a);\n      }\n      const baton = boxMesh(0.35, 0.05, 0.05, '#ffd24a', { roughness: 0.4 });\n      props.add(baton);\n      return {\n        name: ros.name, player: !!ros.player, lane: i, o: laneZ(i),\n        runners, baton, p: 0, v: 0, leg: 0, finished: false, time: 0,\n        nextP: null, nextV: 0,\n        frozen: [null, null, null, null], zoneMsg: false,\n        ai: ros.player ? null : {\n          vmax: 8.6 + Math.random() * 1.0,\n          fumble: [1, 2, 3].map(() => Math.random() < 0.08),\n        },\n      };\n    });\n    // player team in lane 3, mid-track\n    const pi = this.teams.findIndex((t) => t.player);\n    const tmp = this.teams[pi].lane;\n    this.teams[pi].lane = this.teams[3].lane;\n    this.teams[3].lane = tmp;\n    this.teams[pi].o = laneZ(this.teams[pi].lane);\n    this.teams[3].o = laneZ(this.teams[3].lane);\n\n    hud.showTimer(true);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    hud.setGauge(0, '', false);\n    hud.setAttempt(null);\n\n    // paint the exchange zones on each lane — yellow edges, pale ticks between\n    for (const t of this.teams) {\n      for (let k = 1; k <= 3; k++) {\n        const zs = this.legLen * k - 12, ze = this.legLen * k + 9;\n        for (let pp = zs; pp <= ze + 0.01; pp += 2.6) {\n          const pt = this.posFor(t, pp);\n          const edge = pp === zs || pp + 2.6 > ze;\n          const m = boxMesh(edge ? 0.7 : 0.32, 0.05, 1.15, edge ? '#ffd24a' : '#ffedb0');\n          m.position.set(pt.x, 0.11, pt.z);\n          m.rotation.y = -Math.atan2(pt.dz, pt.dx);\n          m.castShadow = false;\n          props.add(m);\n        }\n      }\n    }\n\n    for (const t of this.teams) this.layout(t, 0);\n  },\n\n  posFor(t, p) { return lanePath(t.o, FINISH_S - this.D + p); },\n\n  layout(t, dt) {\n    for (let k = 0; k < 4; k++) {\n      const a = t.runners[k];\n      if (k < t.leg) {\n        if (t.frozen[k]) a.update(dt, 'idle');\n      } else if (k === t.leg) {\n        const pt = this.posFor(t, t.p);\n        a.group.position.set(pt.x, 0, pt.z);\n        a.group.rotation.y = headingFor(pt.dx, pt.dz);\n        const mode = this.phase === 'pre' ? 'crouch'\n          : t.v > 0.6 ? 'run'\n          : (t.finished && t.player ? 'celebrate' : 'idle');\n        a.update(dt, mode, { speed: t.v });\n        t._pt = pt;\n        t.baton.position.set(pt.x + pt.dx * 0.3, 1.05, pt.z + pt.dz * 0.3);\n        t.baton.rotation.y = headingFor(pt.dx, pt.dz);\n      } else {\n        // the receiver actually runs through the zone; others wait at their marks\n        const isReceiver = k === t.leg + 1 && t.nextP != null;\n        const pp = isReceiver ? t.nextP : this.legLen * k;\n        const pt = this.posFor(t, pp);\n        a.group.position.set(pt.x, 0, pt.z);\n        a.group.rotation.y = headingFor(pt.dx, pt.dz);\n        a.update(dt, isReceiver && t.nextV > 0.5 ? 'run' : 'idle', { speed: t.nextV || 0 });\n      }\n    }\n  },\n\n  handoff(t, quality) {\n    t.frozen[t.leg] = true;\n    if (t.nextP != null) {\n      // the baton carries on from wherever the receiver is — no teleporting\n      t.p = Math.max(t.p, t.nextP);\n      t.v = Math.max(t.v * quality, t.nextV);\n    } else {\n      t.v *= quality;\n    }\n    t.leg++;\n    t.nextP = null;\n    t.nextV = 0;\n    t.zoneMsg = false;\n    if (t.player) {\n      game.arrowT = 0; // re-show the marker over your new runner\n      if (quality > 0.9) { hud.showPopup('CLEAN PASS!'); audio.beep(880, 0.15, 'square', 0.15); }\n      else { hud.showPopup('FUMBLED!', true); audio.thud(); game.shake = 0.6; }\n    }\n  },\n\n  go() {\n    this.phase = 'race';\n    audio.gun();\n    game.excitement = 0.7;\n  },\n\n  onMash() {\n    if (this.phase !== 'race') return;\n    const t = this.teams.find((t) => t.player);\n    if (t.finished) return;\n    t.v = Math.min(11.5, t.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (!down || this.phase !== 'race') return;\n    const t = this.teams.find((t) => t.player);\n    if (t.finished || t.leg >= 3 || t.nextP == null) return;\n    // you must actually be within reach of your receiver\n    const gap = t.nextP - t.p;\n    if (gap <= 1.8 && gap >= -1.5) {\n      this.handoff(t, 0.99 - Math.max(0, gap - 0.4) * 0.05);\n    } else if (gap < 9) {\n      hud.showPopup('GET CLOSER!', true);\n    }\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') {\n      for (const t of this.teams) this.layout(t, dt);\n      return;\n    }\n    this.clock += dt;\n\n    for (const t of this.teams) {\n      // the receiver takes off inside the zone as the incoming runner closes in\n      if (!t.finished && t.leg < 3) {\n        const pk = this.legLen * (t.leg + 1);\n        if (t.nextP == null && t.p > pk - 24) { t.nextP = pk - 4; t.nextV = 0; }\n        if (t.nextP != null) {\n          const gap = t.nextP - t.p;\n          if (gap < 11 && gap > 0) t.nextV = Math.min(t.nextV + 7 * dt, Math.max(3.5, t.v * 0.93));\n          t.nextP += t.nextV * dt;\n          if (t.nextP > pk + 9) { t.nextP = pk + 9; t.nextV = 0; } // end of the box — must wait\n        }\n      }\n\n      if (t.ai) {\n        const vt = t.finished ? 2 : t.ai.vmax * (1 - Math.exp(-(this.clock + 0.001) / 1.1));\n        t.v += (vt - t.v) * Math.min(1, dt * 3);\n        if (t.leg < 3 && t.nextP != null && t.nextP - t.p < 1.3) {\n          this.handoff(t, t.ai.fumble[t.leg] ? 0.5 : 0.9 + Math.random() * 0.08);\n        }\n      } else if (t.finished) {\n        t.v += (0 - t.v) * dt * 2.5;\n      } else {\n        t.v *= Math.exp(-1.45 * dt);\n        if (t.leg < 3) {\n          const zoneEnd = this.legLen * (t.leg + 1) + 9;\n          if (t.p > zoneEnd - 21 && !t.zoneMsg) { t.zoneMsg = true; hud.showPopup('CATCH YOUR RUNNER — SPACE!'); }\n          if (t.p >= zoneEnd + 0.5) this.handoff(t, 0.45); // overran the box\n        }\n      }\n\n      if (t.p < this.D + 8) t.p += t.v * dt;\n\n      if (!t.finished && t.p >= this.D) {\n        t.finished = true;\n        t.time = this.clock - (t.p - this.D) / Math.max(t.v, 0.1);\n        if (t.player) {\n          game.excitement = 1;\n          audio.cheer();\n          const pos = this.teams.filter((o) => o.finished && o !== t && o.time < t.time).length + 1;\n          hud.showMsg(['1ST!', '2ND', '3RD', '4TH'][pos - 1], pos === 1 ? 'go' : '');\n          setSlowmo(0.35, 0.8);\n          spawnParticles(new THREE.Vector3(t._pt.x, 1.5, t._pt.z), CONFETTI, 40, { up: 6, vel: 6, life: 1.4 });\n        }\n      }\n      this.layout(t, dt);\n    }\n\n    const p = this.teams.find((t) => t.player);\n    hud.setPower(p.v / 11.5);\n    hud.setGauge(0, 'LEG ' + Math.min(4, p.leg + 1) + ' / 4', true);\n    hud.setTimer((p.finished ? p.time : this.clock).toFixed(2));\n    if (p.v > 1 && !p.finished) {\n      p._stepT = (p._stepT || 0) + dt;\n      if (p._stepT > Math.max(0.16, 3.1 / (4 + p.v * 1.9))) {\n        p._stepT = 0;\n        audio.step();\n      }\n    }\n\n    if (p.finished) {\n      this.endTimer += dt;\n      const allDone = this.teams.every((t) => t.finished);\n      if (allDone || this.endTimer > 4.5) {\n        for (const t of this.teams) {\n          if (!t.finished) { t.finished = true; t.time = this.clock + (this.D - t.p) / Math.max(t.v, 1); }\n        }\n        const rows = this.teams\n          .map((t) => ({ name: t.name, mark: fmtTime(t.time), val: t.time, player: t.player, pts: points.relay(t.time) }))\n          .sort((a, b) => a.val - b.val);\n        bus.finishEvent(rows);\n      }\n    }\n  },\n\n  playerAnchor() {\n    const t = this.teams.find((t) => t.player);\n    if (t.finished) return null;\n    const pt = t._pt || this.posFor(t, t.p);\n    return { x: pt.x, y: 2.1, z: pt.z, dx: pt.dx, dz: pt.dz };\n  },\n\n  camTarget() {\n    const t = this.teams.find((t) => t.player);\n    const pt = t._pt || this.posFor(t, t.p);\n    if (t.finished && this.phase === 'race') {\n      const drift = Math.sin(game.time * 0.6) * 2.2;\n      return {\n        pos: new THREE.Vector3(pt.x + pt.dx * 7.5 - pt.dz * drift, 2.7, pt.z + pt.dz * 7.5 + pt.dx * drift),\n        look: new THREE.Vector3(pt.x, 2.4, pt.z),\n      };\n    }\n    return followCam(pt.x, pt.z, { dx: pt.dx, dz: pt.dz });\n  },\n};\n"},{"name":"races.js","content":"// events/races.js — straight sprints on the home chute: 100m + 110m hurdles\nimport { THREE, clamp } from '../lib.js';\nimport { game, athletes, props, clearProps, followCam, setSlowmo, bus } from '../core.js';\nimport { laneZ, TRACK } from '../world.js';\nimport { makeHurdle } from '../props.js';\nimport * as hud from '../hud.js';\nimport { audio } from '../audio.js';\nimport { spawnParticles, CONFETTI, DUST } from '../fx.js';\nimport { fmtTime, points } from './common.js';\n\nfunction makeStraightRace(cfg) {\n  return {\n    id: cfg.id, name: cfg.name, desc: cfg.desc, falseStart: true,\n    racers: [], clock: 0, phase: 'pre', endTimer: 0, hurdles: [],\n\n    enter() {\n      clearProps();\n      this.phase = 'pre';\n      this.clock = 0;\n      this.endTimer = 0;\n      athletes.forEach((a) => { a.group.visible = true; });\n      this.racers = athletes.map((a, i) => ({\n        a, lane: i, x: TRACK.start - 1.2, v: 0, y: 0, vy: 0, air: false,\n        finished: false, time: 0, _clipped: 0,\n        ai: a.player ? null : {\n          vmax: cfg.aiVmax[0] + Math.random() * (cfg.aiVmax[1] - cfg.aiVmax[0]),\n          ramp: 0.9 + Math.random() * 0.5,\n          mistakes: cfg.hurdleXs ? cfg.hurdleXs.map(() => Math.random() < 0.11) : null,\n        },\n      }));\n      // player runs in lane 3, mid-track\n      const pi = this.racers.findIndex((r) => r.a.player);\n      const tmp = this.racers[pi].lane;\n      this.racers[pi].lane = this.racers[3].lane;\n      this.racers[3].lane = tmp;\n      // guarantee one genuine star in the field\n      const star = this.racers.find((r) => r.ai);\n      if (star) star.ai.vmax = cfg.aiVmax[1] - 0.05;\n\n      this.hurdles = [];\n      if (cfg.hurdleXs) {\n        for (const r of this.racers) {\n          for (const hx of cfg.hurdleXs) {\n            const h = makeHurdle();\n            h.position.set(hx, 0, laneZ(r.lane));\n            h.rotation.y = Math.PI / 2; // bar spans the lane, facing the runners\n            props.add(h);\n            this.hurdles.push({ g: h, x: hx, lane: r.lane, knocked: false, kt: 0 });\n          }\n        }\n      }\n      for (const r of this.racers) {\n        r.a.group.position.set(r.x, 0, laneZ(r.lane));\n        r.a.group.rotation.y = Math.PI / 2;\n      }\n      hud.showTimer(true);\n      hud.setPower(0, 'SPEED — MASH ◀ ▶');\n      hud.setGauge(0, '', false);\n      hud.setAttempt(null);\n    },\n\n    go() {\n      this.phase = 'race';\n      audio.gun();\n      game.excitement = 0.7;\n    },\n\n    onMash() {\n      if (this.phase !== 'race') return;\n      const p = this.racers.find((r) => r.a.player);\n      if (p.finished) return;\n      p.v = Math.min(cfg.vcap, p.v + (p.air ? 0.8 : 1.9));\n    },\n\n    onSpace(down) {\n      if (!down || !cfg.hurdleXs || this.phase !== 'race') return;\n      const p = this.racers.find((r) => r.a.player);\n      if (!p.air && !p.finished) { p.air = true; p.vy = 8.2; audio.whoosh(); }\n    },\n\n    clip(r, hx) {\n      r.v *= 0.5;\n      const h = this.hurdles.find((h) => h.lane === r.lane && h.x === hx);\n      if (h && !h.knocked) { h.knocked = true; audio.thud(); }\n      if (r.a.player) { hud.showPopup('CLIPPED!', true); game.shake = 0.7; }\n    },\n\n    update(dt) {\n      if (this.phase === 'pre') {\n        for (const r of this.racers) r.a.update(dt, 'crouch');\n        return;\n      }\n      this.clock += dt;\n\n      for (const r of this.racers) {\n        if (r.ai) {\n          const vt = r.finished ? 2 : r.ai.vmax * (1 - Math.exp(-this.clock / r.ai.ramp));\n          r.v += (vt - r.v) * Math.min(1, dt * 3);\n        } else if (!r.finished) {\n          r.v *= Math.exp(-1.45 * dt);\n        } else {\n          r.v += (0 - r.v) * dt * 2.5; // pull up and soak in the moment\n        }\n\n        if (cfg.hurdleXs && !r.finished) {\n          const nextHx = cfg.hurdleXs.find((hx) => hx > r.x - 0.6);\n          if (nextHx != null) {\n            const dist = nextHx - r.x;\n            if (r.ai && !r.air && dist < r.v * 0.34 && dist > 0) {\n              const hi = cfg.hurdleXs.indexOf(nextHx);\n              if (!r.ai.mistakes[hi]) { r.air = true; r.vy = 8.2; }\n            }\n            if (Math.abs(dist) < 0.45 && r.y < 0.85 && r._clipped !== nextHx) {\n              r._clipped = nextHx;\n              this.clip(r, nextHx);\n            }\n          }\n        }\n\n        if (r.air) {\n          r.y += r.vy * dt;\n          r.vy -= 24 * dt;\n          if (r.y <= 0) { r.y = 0; r.air = false; }\n        }\n\n        if (r.x < TRACK.finish + 12) r.x += r.v * dt;\n\n        if (!r.finished && r.x >= TRACK.finish) {\n          r.finished = true;\n          r.time = this.clock - (r.x - TRACK.finish) / Math.max(r.v, 0.1);\n          if (r.a.player) {\n            game.excitement = 1;\n            audio.cheer();\n            const pos = this.racers.filter((o) => o.finished && o !== r && o.time < r.time).length + 1;\n            hud.showMsg(['1ST!', '2ND', '3RD', '4TH'][pos - 1], pos === 1 ? 'go' : '');\n            setSlowmo(0.3, 0.9);\n            spawnParticles(new THREE.Vector3(r.x, 1.5, laneZ(r.lane)), CONFETTI, 40, { up: 6, vel: 6, life: 1.4 });\n          }\n        }\n        r.a.group.position.set(r.x, r.y, laneZ(r.lane));\n        const mode = r.air ? 'air' : r.v > 0.6 ? 'run' : (r.finished && !r.ai ? 'celebrate' : 'idle');\n        r.a.update(dt, mode, { speed: r.v });\n\n        if (!r.air && r.v > 7 && !r.finished) {\n          r._dust = (r._dust || 0) + dt;\n          if (r._dust > 0.11) {\n            r._dust = 0;\n            spawnParticles(new THREE.Vector3(r.x - 0.5, 0.12, laneZ(r.lane)), DUST, 2, { up: 1.4, vel: 1.8, life: 0.5, g: 3, size: 0.55, spread: 0.4 });\n          }\n        }\n        // footsteps synced to the player's stride\n        if (!r.ai && !r.air && r.v > 1 && !r.finished) {\n          r._stepT = (r._stepT || 0) + dt;\n          if (r._stepT > Math.max(0.16, 3.1 / (4 + r.v * 1.9))) {\n            r._stepT = 0;\n            audio.step();\n          }\n        }\n      }\n\n      for (const h of this.hurdles) {\n        if (h.knocked && h.kt < 1) {\n          h.kt = Math.min(1, h.kt + dt * 3);\n          h.g.userData.inner.rotation.x = h.kt * 1.35;\n        }\n      }\n\n      const p = this.racers.find((r) => r.a.player);\n      hud.setPower(p.v / cfg.vcap);\n      // the clock freezes on YOUR time the moment you cross the line\n      hud.setTimer((p.finished ? p.time : this.clock).toFixed(2));\n\n      if (p.finished) {\n        this.endTimer += dt;\n        const allDone = this.racers.every((r) => r.finished);\n        if ((allDone && this.endTimer > 3.2) || this.endTimer > 4.6) {\n          for (const r of this.racers) {\n            if (!r.finished) { r.finished = true; r.time = this.clock + (TRACK.finish - r.x) / Math.max(r.v, 1); }\n          }\n          const rows = this.racers\n            .map((r) => ({ name: r.a.name, mark: fmtTime(r.time), val: r.time, player: r.a.player, pts: cfg.pts(r.time) }))\n            .sort((a, b) => a.val - b.val);\n          bus.finishEvent(rows);\n        }\n      }\n    },\n\n    playerAnchor() {\n      const p = this.racers.find((r) => r.a.player);\n      if (p.finished) return null;\n      return { x: p.x, y: p.y + 2.1, z: laneZ(p.lane), dx: 1, dz: 0 };\n    },\n\n    camTarget() {\n      const p = this.racers.find((r) => r.a.player);\n      if (p.finished && this.phase === 'race') {\n        // swoop down in front of the athlete to catch the celebration\n        const drift = Math.sin(game.time * 0.6) * 2.2;\n        return {\n          pos: new THREE.Vector3(p.x + 7.5, 2.7, laneZ(p.lane) + 2.5 + drift),\n          look: new THREE.Vector3(p.x, 2.4, laneZ(p.lane)),\n        };\n      }\n      return followCam(clamp(p.x, TRACK.start - 2, TRACK.finish + 8), laneZ(p.lane));\n    },\n  };\n}\n\nexport const sprint100 = makeStraightRace({\n  id: 'sprint100', name: '100M SPRINT',\n  desc: 'Pure speed. Alternate ◀ ▶ (or A/D) as fast as you can and hit the line first. Jumping the gun restarts the race.',\n  vcap: 11.5, aiVmax: [10.4, 11.4], pts: points.sprint100,\n});\n\nexport const hurdles110 = makeStraightRace({\n  id: 'hurdles110', name: '110M HURDLES',\n  desc: 'Mash ◀ ▶ for speed, tap SPACE to leap each hurdle. Clip one and you stumble.',\n  vcap: 11.2, aiVmax: [8.2, 9.4],\n  hurdleXs: [-43.3, -34.2, -25.1, -15.9, -6.8, 2.3, 11.5, 20.6, 29.8],\n  pts: points.hurdles110,\n});\n"},{"name":"throws.js","content":"// events/throws.js — javelin (runway) + hammer & discus (spin circles)\nimport { THREE, clamp, boxMesh } from '../lib.js';\nimport { game, athletes, player, props, clearProps, followCam, bus } from '../core.js';\nimport * as hud from '../hud.js';\nimport { audio } from '../audio.js';\nimport { spawnParticles, DIRT } from '../fx.js';\nimport { makeJavelin, makeHammer, makeDiscus, makeShot, makeDistanceFlag } from '../props.js';\nimport { fmtDist, points } from './common.js';\n\nfunction parkAI(x, z) {\n  athletes.filter((a) => !a.player).forEach((a, i) => {\n    a.group.position.set(x + i * 2.5, 0, z);\n    a.group.rotation.y = Math.PI;\n  });\n}\n\n// ---------------------------------------------------------------- javelin\nconst JV_LINE = -20; // foul line x; runway z = 0\nexport const javelin = {\n  id: 'javelin', name: 'JAVELIN', falseStart: false,\n  desc: 'Charge down the runway with ◀ ▶, hold SPACE to raise the angle, release to hurl it before the line. Two attempts.',\n  attempt: 0, best: 0, marks: [], phase: 'pre',\n  x: 0, v: 0, angle: 0, angleDir: 1, foul: false,\n  jav: null, jx: 0, jy: 0, jvx: 0, jvy: 0, landTimer: 0, aiRolls: [],\n\n  enter() {\n    clearProps();\n    this.attempt = 0;\n    this.best = 0;\n    this.marks = [];\n    athletes.forEach((a) => { a.group.visible = true; });\n    this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n      name: a.name, d: 40 + Math.random() * 15,\n    }));\n    this.jav = makeJavelin();\n    props.add(this.jav);\n    this.resetAttempt();\n    hud.showTimer(false);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    parkAI(-34, -6);\n  },\n\n  resetAttempt() {\n    this.phase = this.attempt > 0 ? 'run' : 'pre';\n    this.x = -43; this.v = 0; this.angle = 0; this.foul = false; this.landTimer = 0;\n    player.group.position.set(this.x, 0, 0);\n    player.group.rotation.y = Math.PI / 2;\n    hud.setAttempt(this.attempt + 1, 2);\n    hud.setGauge(0, '', false);\n    this.holdJavelin();\n    if (this.attempt > 0) hud.showMsg('ATTEMPT 2', 'go');\n  },\n\n  holdJavelin() {\n    this.jav.position.set(this.x + 0.15, 1.62, 0.32);\n    this.jav.rotation.z = 0.18;\n  },\n\n  go() { this.phase = 'run'; game.excitement = 0.6; },\n\n  onMash() {\n    if (this.phase === 'run' || this.phase === 'charge') this.v = Math.min(11.0, this.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (down && this.phase === 'run') {\n      this.phase = 'charge';\n      this.angle = 20;\n      this.angleDir = 1;\n    } else if (!down && this.phase === 'charge') {\n      this.throwIt(false);\n    }\n  },\n\n  throwIt(forceFoul) {\n    this.foul = forceFoul || this.x > JV_LINE + 0.4;\n    const rad = this.angle * Math.PI / 180;\n    const vt = this.v * 1.0 + 12;\n    this.jvx = Math.cos(rad) * vt;\n    this.jvy = Math.sin(rad) * vt;\n    this.jx = this.x + 0.5;\n    this.jy = 1.7;\n    this.phase = 'flight';\n    audio.whoosh();\n    game.excitement = 0.8;\n  },\n\n  landJav() {\n    this.phase = 'land';\n    this.landTimer = 0;\n    audio.thud();\n    const d = this.foul ? 0 : this.jx - JV_LINE;\n    if (this.foul || d <= 8) {\n      this.marks.push(null);\n      hud.showMsg('FOUL', 'bad');\n    } else {\n      this.marks.push(d);\n      this.best = Math.max(this.best, d);\n      hud.showPopup(fmtDist(d) + '!');\n      game.excitement = Math.min(1, 0.4 + d / 80);\n      if (d > 50) audio.cheer();\n      const fg = makeDistanceFlag();\n      fg.position.set(this.jx, 0, 0);\n      props.add(fg);\n    }\n    spawnParticles(new THREE.Vector3(this.jx, 0.3, 0), DIRT, 25, { up: 3, vel: 4, life: 0.8, g: 16 });\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') { player.update(dt, 'idle'); this.holdJavelin(); return; }\n\n    if (this.phase === 'run' || this.phase === 'charge') {\n      this.v *= Math.exp(-1.45 * dt);\n      this.x += this.v * dt;\n      if (this.phase === 'charge') {\n        this.angle += this.angleDir * 65 * dt;\n        if (this.angle > 80) { this.angle = 80; this.angleDir = -1; }\n        if (this.angle < 18) { this.angle = 18; this.angleDir = 1; }\n        hud.setGauge(this.angle / 90, 'ANGLE ' + Math.round(this.angle) + '°', true);\n      }\n      if (this.x > JV_LINE + 0.4 && this.phase !== 'flight') {\n        this.throwIt(true);\n        hud.showMsg('FOUL', 'bad');\n      } else {\n        player.group.position.set(this.x, 0, 0);\n        player.update(dt, this.phase === 'charge' ? 'charge' : this.v > 0.5 ? 'throwRun' : 'idle', { speed: this.v, jav: true });\n        this.holdJavelin();\n        hud.setPower(this.v / 11.0);\n        if (this.v > 1) {\n          this._stepT = (this._stepT || 0) + dt;\n          if (this._stepT > Math.max(0.16, 3.1 / (4 + this.v * 1.9))) { this._stepT = 0; audio.step(); }\n        }\n      }\n    } else if (this.phase === 'flight') {\n      this.jvy -= 9.1 * dt;\n      this.jvx *= (1 - 0.06 * dt);\n      this.jx += this.jvx * dt;\n      this.jy += this.jvy * dt;\n      this.jav.position.set(this.jx, Math.max(this.jy, 0.15), 0);\n      this.jav.rotation.z = Math.atan2(this.jvy, this.jvx);\n      if (this.jy <= 0.15) this.landJav();\n      this.v *= Math.exp(-2.5 * dt);\n      this.x += this.v * dt;\n      player.group.position.set(Math.min(this.x, JV_LINE - 0.5), 0, 0);\n      player.update(dt, this.v > 0.5 ? 'run' : 'idle', { speed: this.v });\n    } else if (this.phase === 'land') {\n      player.update(dt, this.marks[this.marks.length - 1] ? 'celebrate' : 'dejected');\n      this.landTimer += dt;\n      if (this.landTimer > 2.4) {\n        this.attempt++;\n        if (this.attempt < 2) this.resetAttempt();\n        else this.finish();\n      }\n    }\n  },\n\n  finish() {\n    const rows = [\n      { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'FOUL', val: this.best, player: true, pts: this.best > 0 ? points.javelin(this.best) : 0 },\n      ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: points.javelin(r.d) })),\n    ].sort((a, b) => b.val - a.val);\n    bus.finishEvent(rows);\n  },\n\n  replayProp() { return this.jav; },\n\n  camTarget() {\n    if (this.phase === 'flight') {\n      return {\n        pos: new THREE.Vector3(this.jx - 4, Math.max(this.jy, 4) + 40, 30),\n        look: new THREE.Vector3(this.jx, Math.max(this.jy, 1), 0),\n      };\n    }\n    if (this.phase === 'land') {\n      const px = Math.min(this.x, JV_LINE - 0.5);\n      return {\n        pos: new THREE.Vector3(px + 6.5, 2.6, 2.5),\n        look: new THREE.Vector3(px, 2.3, 0),\n      };\n    }\n    return followCam(clamp(this.x, -43, -18), 0);\n  },\n};\n\n// ---------------------------------------------------------------- spin throws\n// Mash ◀ ▶ to wind up the spin, SPACE to release. The projectile flies along\n// the release tangent — release as your athlete swings toward the sector.\nfunction makeSpinThrow(cfg) {\n  return {\n    id: cfg.id, name: cfg.name, desc: cfg.desc, falseStart: false,\n    attempt: 0, best: 0, marks: [], phase: 'pre',\n    theta: 0, omega: 0, foul: false,\n    proj: null, px: 0, py: 0, pz: 0, pvx: 0, pvy: 0, pvz: 0,\n    landTimer: 0, aiRolls: [], arrow: null,\n\n    enter() {\n      clearProps();\n      this.attempt = 0;\n      this.best = 0;\n      this.marks = [];\n      athletes.forEach((a) => { a.group.visible = true; });\n      this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n        name: a.name, d: cfg.aiRange[0] + Math.random() * (cfg.aiRange[1] - cfg.aiRange[0]),\n      }));\n      this.proj = cfg.makeProj();\n      props.add(this.proj);\n      // release-direction arrow on the ground\n      this.arrow = boxMesh(2.6, 0.12, 0.5, '#7ce34a', { emissive: new THREE.Color('#2f8f1f'), emissiveIntensity: 0.5 });\n      this.arrow.position.set(cfg.cx, 0.25, cfg.cz);\n      props.add(this.arrow);\n      this.resetAttempt();\n      hud.showTimer(false);\n      hud.setPower(0, 'SPIN — MASH ◀ ▶');\n      parkAI(cfg.cx + 4, cfg.cz + 7);\n    },\n\n    resetAttempt() {\n      this.phase = this.attempt > 0 ? 'spin' : 'pre';\n      this.theta = cfg.sector - Math.PI; // start swinging away from the sector\n      this.omega = 0;\n      this.foul = false;\n      this.landTimer = 0;\n      player.group.position.set(cfg.cx, 0, cfg.cz);\n      hud.setAttempt(this.attempt + 1, 2);\n      hud.setGauge(0, '', false);\n      this.arrow.visible = true;\n      if (this.attempt > 0) hud.showMsg('ATTEMPT 2', 'go');\n    },\n\n    go() { this.phase = 'spin'; game.excitement = 0.6; },\n\n    onMash() {\n      if (this.phase !== 'spin') return;\n      this.omega = Math.min(cfg.omegaMax, this.omega + 0.62);\n    },\n\n    onSpace(down) {\n      if (down && this.phase === 'spin' && this.omega > 1.2) this.release();\n    },\n\n    release() {\n      // release direction = tangent of the swing, with a forgiving aim assist\n      let dir = this.theta + Math.PI / 2;\n      let err = Math.atan2(Math.sin(dir - cfg.sector), Math.cos(dir - cfg.sector));\n      const assisted = Math.sign(err) * Math.max(0, Math.abs(err) - 0.35);\n      dir = cfg.sector + assisted;\n      this.foul = Math.abs(assisted) > 0.35;\n      const vt = this.omega * cfg.vFactor * (1 - Math.min(0.3, Math.abs(assisted) * 0.5));\n      const elev = cfg.elev * Math.PI / 180;\n      this.pvx = Math.cos(dir) * Math.cos(elev) * vt;\n      this.pvz = Math.sin(dir) * Math.cos(elev) * vt;\n      this.pvy = Math.sin(elev) * vt;\n      this.px = cfg.cx + Math.cos(this.theta) * 1.2;\n      this.pz = cfg.cz + Math.sin(this.theta) * 1.2;\n      this.py = 1.5;\n      this.phase = 'flight';\n      audio.whoosh();\n      game.excitement = 0.8;\n      this.arrow.visible = false;\n      if (this.foul) hud.showMsg('OUT OF SECTOR', 'bad');\n    },\n\n    landProj() {\n      this.phase = 'land';\n      this.landTimer = 0;\n      audio.thud();\n      player.group.rotation.y = Math.PI / 2 + 0.3; // square up to the camera\n      const d = Math.hypot(this.px - cfg.cx, this.pz - cfg.cz);\n      if (this.foul || d < cfg.minMark) {\n        this.marks.push(null);\n        hud.showMsg('FOUL', 'bad');\n      } else {\n        this.marks.push(d);\n        this.best = Math.max(this.best, d);\n        hud.showPopup(fmtDist(d) + '!');\n        game.excitement = Math.min(1, 0.4 + d / 90);\n        if (d > cfg.cheerAt) audio.cheer();\n        const fg = makeDistanceFlag('#39d4c8');\n        fg.position.set(this.px, 0, this.pz);\n        props.add(fg);\n      }\n      spawnParticles(new THREE.Vector3(this.px, 0.3, this.pz), DIRT, 25, { up: 3, vel: 4, life: 0.8, g: 16 });\n    },\n\n    update(dt) {\n      if (this.phase === 'pre') {\n        player.update(dt, 'idle');\n        this.holdProj();\n        return;\n      }\n      if (this.phase === 'spin') {\n        this.omega = Math.max(0, this.omega - cfg.decay * dt);\n        this.theta += this.omega * dt;\n        player.group.position.set(cfg.cx, 0, cfg.cz);\n        player.group.rotation.y = -this.theta + Math.PI / 2;\n        player.update(dt, this.omega > 0.5 ? 'spin' : 'idle');\n        this.holdProj();\n        // arrow shows the release tangent; green when inside the sector\n        const dir = this.theta + Math.PI / 2;\n        const err = Math.abs(Math.atan2(Math.sin(dir - cfg.sector), Math.cos(dir - cfg.sector)));\n        this.arrow.rotation.y = -dir;\n        this.arrow.position.set(cfg.cx + Math.cos(dir) * 2.6, 0.25, cfg.cz + Math.sin(dir) * 2.6);\n        this.arrow.material.color.set(err < 0.7 ? '#7ce34a' : '#ff4b3a');\n        hud.setPower(this.omega / cfg.omegaMax);\n        hud.setGauge(Math.max(0, 1 - err / Math.PI), err < 0.7 ? 'RELEASE NOW!' : 'WAIT FOR IT…', true);\n      } else if (this.phase === 'flight') {\n        this.pvy -= cfg.g * dt;\n        this.px += this.pvx * dt;\n        this.pz += this.pvz * dt;\n        this.py += this.pvy * dt;\n        this.proj.position.set(this.px, Math.max(this.py, 0.15), this.pz);\n        this.proj.rotation.y += dt * 9;\n        if (this.py <= 0.15) this.landProj();\n        player.update(dt, 'idle');\n      } else if (this.phase === 'land') {\n        player.update(dt, this.marks[this.marks.length - 1] ? 'celebrate' : 'dejected');\n        this.landTimer += dt;\n        if (this.landTimer > 2.4) {\n          this.attempt++;\n          if (this.attempt < 2) this.resetAttempt();\n          else this.finish();\n        }\n      }\n    },\n\n    holdProj() {\n      // held at arm's length while spinning\n      this.proj.position.set(\n        cfg.cx + Math.cos(this.theta) * cfg.holdR,\n        1.35,\n        cfg.cz + Math.sin(this.theta) * cfg.holdR\n      );\n      this.proj.rotation.y = -this.theta;\n    },\n\n    finish() {\n      const rows = [\n        { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'FOUL', val: this.best, player: true, pts: this.best > 0 ? cfg.pts(this.best) : 0 },\n        ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: cfg.pts(r.d) })),\n      ].sort((a, b) => b.val - a.val);\n      bus.finishEvent(rows);\n    },\n\n    replayProp() { return this.proj; },\n\n    camTarget() {\n      if (this.phase === 'flight') {\n        return {\n          pos: new THREE.Vector3(this.px - 6, Math.max(this.py, 4) + 38, this.pz + 28),\n          look: new THREE.Vector3(this.px, Math.max(this.py, 1), this.pz),\n        };\n      }\n      if (this.phase === 'land') {\n        return {\n          pos: new THREE.Vector3(cfg.cx + 6.5, 2.6, cfg.cz + 3),\n          look: new THREE.Vector3(cfg.cx, 2.3, cfg.cz),\n        };\n      }\n      return followCam(cfg.cx, cfg.cz, { h: 34, back: 18, lead: 1 });\n    },\n  };\n}\n\nexport const hammer = makeSpinThrow({\n  id: 'hammer', name: 'HAMMER THROW',\n  desc: 'Mash ◀ ▶ to whirl the hammer faster and faster, then tap SPACE to let it fly as the green arrow sweeps into the sector. Two attempts.',\n  cx: -36, cz: -14, sector: -0.15,\n  omegaMax: 13, decay: 3.2, vFactor: 2.0, elev: 38, g: 9.8,\n  holdR: 1.5, minMark: 15, cheerAt: 60, aiRange: [48, 66],\n  makeProj: makeHammer, pts: points.hammer,\n});\n\nexport const discus = makeSpinThrow({\n  id: 'discus', name: 'DISCUS',\n  desc: 'Same whirl, different disc — spin up with ◀ ▶ and release with SPACE into the sector. It glides further than you think. Two attempts.',\n  cx: -36, cz: 14, sector: 0.1,\n  omegaMax: 13, decay: 3.2, vFactor: 1.72, elev: 35, g: 8.0,\n  holdR: 1.1, minMark: 12, cheerAt: 48, aiRange: [38, 54],\n  makeProj: makeDiscus, pts: points.discus,\n});\n\nexport const shotPut = makeSpinThrow({\n  id: 'shotPut', name: 'SHOT PUT',\n  desc: 'Whirl in the circle with ◀ ▶ and punch the shot out with SPACE as the arrow sweeps into the sector. It is heavy — no glide, all grunt. Two attempts.',\n  cx: -36, cz: -14, sector: -0.15,\n  omegaMax: 13, decay: 3.4, vFactor: 1.12, elev: 40, g: 9.8,\n  holdR: 0.6, minMark: 6, cheerAt: 19, aiRange: [13.5, 18.5],\n  makeProj: makeShot, pts: points.shotPut,\n});\n"},{"name":"vault.js","content":"// events/vault.js — pole vault: sprint, plant in the box, release at the top\n// of the swing. Shares the jumps runway (z=24), bar line at x=8.\nimport { THREE, clamp } from '../lib.js';\nimport { game, athletes, player, props, clearProps, followCam, bus } from '../core.js';\nimport * as hud from '../hud.js';\nimport { audio } from '../audio.js';\nimport { spawnParticles, SAND } from '../fx.js';\nimport { makeVaultKit, makeVaultPole } from '../props.js';\nimport { fmtDist, points } from './common.js';\n\nconst RUNWAY_Z = 24;\nconst BAR_X = 8;\nconst PIVOT_X = 6.2;      // plant box\nconst POLE_LEN = 4.6;\nconst PLANT_X = PIVOT_X - POLE_LEN * 0.93; // where your hands are when the tip hits the box\nconst G = 12.5;\n\nexport const poleVault = {\n  id: 'poleVault', name: 'POLE VAULT', falseStart: false,\n  desc: 'Sprint with ◀ ▶, press & hold SPACE to plant the pole in the box, then let go at the top of the swing to fire yourself over. Bar starts at 4.40m and rises 20cm per clearance — three misses ends it.',\n  phase: 'pre', barH: 4.4, best: 0, misses: 0, started: false,\n  x: 0, v: 0, y: 0, vx: 0, vy: 0,\n  swing: 0, a0: 0, plantV: 0, poleDrop: null, poleRot0: 0,\n  cleared: false, crossed: false, landTimer: 0, barFall: null,\n  aiRolls: [], kit: null, pole: null,\n\n  enter() {\n    clearProps();\n    this.barH = 4.4; this.best = 0; this.misses = 0; this.started = false;\n    athletes.forEach((a) => { a.group.visible = true; });\n    this.aiRolls = athletes.filter((a) => !a.player).map((a) => ({\n      name: a.name, d: 4.6 + Math.floor(Math.random() * 5) * 0.2,\n    }));\n    this.kit = makeVaultKit();\n    this.kit.position.set(BAR_X, 0, RUNWAY_Z);\n    props.add(this.kit);\n    this.pole = makeVaultPole();\n    props.add(this.pole);\n    this.resetAttempt();\n    hud.showTimer(false);\n    hud.setPower(0, 'SPEED — MASH ◀ ▶');\n    athletes.filter((a) => !a.player).forEach((a, i) => {\n      a.group.position.set(-20 + i * 2.5, 0, 29);\n      a.group.rotation.y = Math.PI;\n    });\n  },\n\n  resetAttempt() {\n    this.phase = this.started ? 'run' : 'pre';\n    this.x = -28; this.v = 0; this.y = 0; this.vx = 0; this.vy = 0;\n    this.swing = 0; this.a0 = 0; this.plantV = 0;\n    this.cleared = false; this.crossed = false;\n    this.landTimer = 0; this.barFall = null; this.poleDrop = null;\n    player.group.position.set(this.x, 0, RUNWAY_Z);\n    player.group.rotation.y = Math.PI / 2;\n    const bar = this.kit.userData.bar;\n    bar.position.set(0, this.barH, 0);\n    bar.rotation.set(0, 0, 0);\n    this.holdPole();\n    hud.setGauge(0, '', false);\n    hud.hud.attempt.textContent = 'BAR ' + this.barH.toFixed(2) + 'M · MISSES ' + this.misses + ' / 3';\n    hud.hud.attempt.classList.remove('hidden');\n  },\n\n  holdPole() {\n    this.pole.position.set(this.x + 0.4, 1.25, RUNWAY_Z + 0.35);\n    this.pole.rotation.z = 0.14;\n  },\n\n  go() { this.phase = 'run'; this.started = true; game.excitement = 0.6; },\n\n  onMash() {\n    if (this.phase === 'run') this.v = Math.min(11.0, this.v + 1.9);\n  },\n\n  onSpace(down) {\n    if (down && this.phase === 'run') {\n      if (this.x >= PLANT_X - 1.6) this.plant();\n      else hud.showPopup('NOT YET!', true);\n    } else if (!down && this.phase === 'swing') {\n      this.launch(this.swing, false);\n    }\n  },\n\n  plant() {\n    this.plantV = this.v;\n    this.a0 = Math.acos(clamp((PIVOT_X - this.x) / POLE_LEN, 0.06, 0.98));\n    this.swing = 0;\n    this.phase = 'swing';\n    audio.thud();\n    game.excitement = 0.75;\n  },\n\n  swingAngle() {\n    return this.a0 + Math.min(this.swing, 1) * (Math.PI / 2 - this.a0);\n  },\n\n  launch(p, weak) {\n    const a = this.a0 + Math.min(p, 1) * (Math.PI / 2 - this.a0);\n    this.x = PIVOT_X - Math.cos(a) * POLE_LEN;\n    this.y = Math.max(0.4, Math.sin(a) * POLE_LEN - 1.5);\n    this.vy = weak ? 1.8 : this.plantV * (0.25 + 0.55 * Math.min(p, 1));\n    this.vx = weak ? 1.0 : 1.8 + Math.min(p, 1) * 0.9;\n    this.phase = 'flight';\n    this.poleDrop = 0;\n    this.poleRot0 = Math.PI - a;\n    audio.whoosh();\n  },\n\n  knockBar() {\n    this.barFall = 0;\n    audio.thud();\n    game.shake = 0.5;\n  },\n\n  update(dt) {\n    if (this.phase === 'pre') { player.update(dt, 'crouch'); this.holdPole(); return; }\n\n    if (this.barFall != null && this.barFall < 1.4) {\n      this.barFall += dt;\n      const bar = this.kit.userData.bar;\n      bar.position.y = Math.max(1.05, this.barH - this.barFall * 3.4);\n      bar.position.x += dt * 1.8;\n      bar.rotation.x += dt * 2.6;\n    }\n    if (this.poleDrop != null) {\n      this.poleDrop += dt;\n      this.pole.position.set(PIVOT_X, 0.12, RUNWAY_Z);\n      this.pole.rotation.z = Math.min(Math.PI - 0.06, this.poleRot0 + this.poleDrop * this.poleDrop * 1.6);\n    }\n\n    if (this.phase === 'run') {\n      this.v *= Math.exp(-1.45 * dt);\n      this.x += this.v * dt;\n      if (this.x > PLANT_X + 0.9) {\n        // ran past the box — a desperate hop into the mat\n        this.plantV = this.v;\n        this.a0 = 0.4;\n        this.launch(0.05, true);\n        hud.showPopup('MISSED THE BOX!', true);\n      } else {\n        player.group.position.set(this.x, 0, RUNWAY_Z);\n        player.update(dt, this.v > 0.5 ? 'throwRun' : 'idle', { speed: this.v });\n        this.holdPole();\n        hud.setPower(this.v / 11.0);\n        if (this.v > 1) {\n          this._stepT = (this._stepT || 0) + dt;\n          if (this._stepT > Math.max(0.16, 3.1 / (4 + this.v * 1.9))) { this._stepT = 0; audio.step(); }\n        }\n      }\n    } else if (this.phase === 'swing') {\n      this.swing += dt * (0.75 + this.plantV * 0.055);\n      if (this.swing > 1.18) {\n        this.launch(1, true);\n        hud.showPopup('HELD TOO LONG!', true);\n        return;\n      }\n      const a = this.swingAngle();\n      this.x = PIVOT_X - Math.cos(a) * POLE_LEN;\n      this.y = Math.max(0, Math.sin(a) * POLE_LEN - 1.5);\n      player.group.position.set(this.x, this.y, RUNWAY_Z);\n      player.update(dt, 'air');\n      this.pole.position.set(PIVOT_X, 0.12, RUNWAY_Z);\n      this.pole.rotation.z = Math.PI - a;\n      hud.setGauge(Math.min(this.swing, 1), this.swing > 0.8 ? 'RELEASE NOW!' : 'SWING…', true);\n    } else if (this.phase === 'flight') {\n      const px = this.x;\n      this.vy -= G * dt;\n      this.x += this.vx * dt;\n      this.y += this.vy * dt;\n      if (!this.crossed && px < BAR_X && this.x >= BAR_X) {\n        this.crossed = true;\n        if (this.y >= this.barH - 0.35) this.cleared = true; // draped clearance\n        else this.knockBar();\n      }\n      const onMat = this.x > BAR_X + 0.5;\n      const floor = onMat ? 1.02 : 0;\n      if (this.y <= floor) { this.y = floor; this.land(); }\n      player.group.position.set(this.x, this.y, RUNWAY_Z);\n      player.update(dt, 'air');\n    } else if (this.phase === 'land') {\n      player.update(dt, this.cleared ? 'celebrate' : 'dejected');\n      this.landTimer += dt;\n      if (this.landTimer > 2.2) this.resolve();\n    } else if (this.phase === 'done') {\n      player.update(dt, this.cleared ? 'celebrate' : 'dejected');\n    }\n  },\n\n  land() {\n    this.phase = 'land';\n    this.landTimer = 0;\n    audio.thud();\n    if (!this.crossed) {\n      this.cleared = false;\n      hud.showPopup('TOO EARLY!', true);\n    }\n    if (this.cleared) {\n      hud.showPopup(this.barH.toFixed(2) + 'M CLEARED!');\n      game.excitement = Math.min(1, 0.4 + (this.barH - 4.2) / 2);\n      if (this.barH > 5.1) audio.cheer();\n    } else {\n      hud.showMsg('MISS', 'bad');\n    }\n    spawnParticles(new THREE.Vector3(this.x, this.y + 0.2, RUNWAY_Z), SAND, 18, { up: 2.5, vel: 3, life: 0.6, g: 16 });\n  },\n\n  resolve() {\n    if (this.cleared) {\n      this.best = this.barH;\n      this.barH = Math.round((this.barH + 0.2) * 100) / 100;\n    } else {\n      this.misses++;\n    }\n    if (this.misses >= 3 || this.barH > 6.4) { this.phase = 'done'; this.finish(); }\n    else this.resetAttempt();\n  },\n\n  finish() {\n    const rows = [\n      { name: player.name, mark: this.best > 0 ? fmtDist(this.best) : 'NO MARK', val: this.best, player: true, pts: this.best > 0 ? points.poleVault(this.best) : 0 },\n      ...this.aiRolls.map((r) => ({ name: r.name, mark: fmtDist(r.d), val: r.d, player: false, pts: points.poleVault(r.d) })),\n    ].sort((a, b) => b.val - a.val);\n    bus.finishEvent(rows);\n  },\n\n  camTarget() {\n    if (this.phase === 'swing' || this.phase === 'flight' || this.phase === 'land') {\n      // pulled-back side shot — the whole vault arc stays in frame\n      return {\n        pos: new THREE.Vector3(this.x - 4.5, Math.max(this.y, 2) + 3.4, RUNWAY_Z + 16),\n        look: new THREE.Vector3(this.x, Math.max(this.y, 1.6), RUNWAY_Z),\n      };\n    }\n    return followCam(clamp(this.x, -28, 7), RUNWAY_Z);\n  },\n};\n"}],"folders":[]}]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[],"collapsed":true}]},"variants":null,"createdAt":"2026-07-12T18:26:04.259Z","updatedAt":"2026-07-15T13:49:23.986Z","hasThumbnail":true}}