{"project":{"id":"AbifYBV","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Super Voxel Football","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 Football</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=\"score-chip\">\n        <span id=\"half-chip\">FIRST HALF</span>\n        <span id=\"score-line\">\n          <span class=\"dot home\"></span>\n          <span id=\"score-home-name\">VOX</span>\n          <span id=\"score-home\">0</span>\n          <span class=\"vs\">&mdash;</span>\n          <span id=\"score-away\">0</span>\n          <span id=\"score-away-name\">DYN</span>\n          <span class=\"dot away\"></span>\n        </span>\n      </div>\n      <div id=\"timer\">00:00</div>\n    </div>\n    <div id=\"center-msg\"></div>\n    <div id=\"popup\"></div>\n    <div id=\"meters\">\n      <div id=\"power-wrap\" class=\"hidden\">\n        <div id=\"power-fill\"></div>\n        <span class=\"meter-label\" id=\"power-label\">SHOOT POWER</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\"></div>\n      <h2 id=\"card-name\"></h2>\n      <p id=\"card-desc\"></p>\n      <div class=\"card-hint blink\">PRESS <b>ENTER</b> TO START</div>\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\">HALF TIME</div>\n      <h2 id=\"results-title\">0 &mdash; 0</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\">TERRACES &middot; TURF &middot; GLORY</div>\n      <h1>SUPER<br>VOXEL<br>FOOTBALL</h1>\n      <p class=\"title-sub\">A voxel love-letter to Sensible Soccer &amp; Kick Off.<br>5-a-side. Two halves. No VAR.</p>\n      <div class=\"card-hint blink\">PRESS <b>ENTER</b> TO KICK OFF</div>\n      <div class=\"title-controls\">ARROWS / WASD MOVE &nbsp;&middot;&nbsp; SPACE TAP PASS &middot; HOLD SHOOT &nbsp;&middot;&nbsp; NO BALL? SPACE SLIDES</div>\n      <div class=\"title-controls touch-only\">LEFT STICK MOVE &nbsp;&middot;&nbsp; BUTTON TAP PASS &middot; HOLD SHOOT &nbsp;&middot;&nbsp; BEST IN LANDSCAPE</div>\n    </div>\n  </div>\n\n  <div id=\"touch-ui\">\n    <div id=\"t-stick\"><div id=\"t-nub\"></div></div>\n    <button id=\"t-action\">KICK</button>\n  </div>\n</div>\n\n<script src=\"./js/main.js\" type=\"module\"></script>\n\n</body>\n</html>\n"},{"name":"README.md","content":"# Super Voxel Football\n\nA voxel 3D 5-a-side football game in the spirit of Sensible Soccer and\nKick Off — same stadium DNA as Super Voxel Olympics: big-headed voxel\nplayers, instanced crowd, synthesized WebAudio terraces.\n\nVOXEL UTD (you, in red) vs DYNAMO BLU. Two halves of 45 scaled minutes\n(2 real minutes each), kick-offs, throw-ins, corners, goal kicks, diving\nkeepers, slide tackles and goal celebrations with confetti.\n\n## Controls\n\n- **ARROWS / WASD** — move (control auto-switches to the red player nearest the ball)\n- **SPACE tap** — pass to the best-placed teammate\n- **SPACE hold + release** — shoot (power meter appears)\n- **SPACE without the ball** — slide tackle\n- **ENTER** — advance screens\n- Touch: left drag-stick to move, KICK button (tap pass / hold shoot)\n\n## Code layout\n\n- `js/lib.js` — three.js + shared helpers\n- `js/world.js` — the ground (pitch, markings, goals, stands, crowd, floodlights)\n- `js/athlete.js` — articulated voxel footballer + animation modes (run, kick, tackle, dive…)\n- `js/ball.js` — arcade ball physics (bounce, posts, net)\n- `js/ai.js` — team brains: formation, chasing, passing, shooting, keepers\n- `js/core.js` — renderer, camera, squads, shared state\n- `js/hud.js`, `js/audio.js`, `js/fx.js`\n- `js/main.js` — match flow, input, camera, render loop\n"}],"folders":[{"folder":"css","files":[{"name":"styles.css","content":":root {\n  --accent: #ff4b3a;\n  --amber: #ffb020;\n  --lime: #7ce34a;\n  --blue: #4aa3ff;\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#score-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#half-chip {\n  font-size: 0.65rem; letter-spacing: 3px; font-weight: 700;\n  color: var(--amber);\n}\n#score-line {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: 1.35rem; letter-spacing: 1px;\n  display: flex; align-items: center; gap: 10px;\n  font-variant-numeric: tabular-nums;\n}\n#score-line .dot {\n  width: 12px; height: 12px; border-radius: 3px;\n  display: inline-block;\n}\n#score-line .dot.home { background: var(--accent); }\n#score-line .dot.away { background: #2563eb; }\n#score-line .vs { color: #8fa3c8; font-size: 0.9rem; }\n\n#timer {\n  font-family: 'Archivo Black', sans-serif;\n  font-size: 2.2rem;\n  letter-spacing: 1px;\n  background: var(--glass);\n  border: 1px solid var(--glass-line);\n  border-radius: 14px;\n  padding: 8px 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/* power meter */\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 {\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 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/* center message (GOAL!, KICK OFF, FULL TIME) */\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  white-space: nowrap;\n  z-index: 12;\n}\n#center-msg.pop {\n  animation: msgpop 1.1s 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 (scorer, corner, save) */\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.4rem, 4vw, 2.6rem);\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  z-index: 12;\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-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 {\n  color: #e6eeff;\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: #c6d4ee;\n}\n\n/* results table */\n#results-table { margin: 6px 0 26px; max-height: 40vh; overflow-y: auto; }\n.res-row {\n  display: grid;\n  grid-template-columns: 52px 1fr 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.05rem;\n  color: var(--amber);\n  font-variant-numeric: tabular-nums;\n}\n.res-name { letter-spacing: 1px; }\n.res-mark { font-variant-numeric: tabular-nums; color: #cfe0ff; font-weight: 700; letter-spacing: 2px; }\n\n/* ---------- touch controls ---------- */\n#touch-ui { position: fixed; inset: 0; z-index: 30; pointer-events: none; }\n#t-stick {\n  pointer-events: auto;\n  position: absolute; left: 22px; bottom: 24px;\n  width: 132px; height: 132px;\n  border-radius: 50%;\n  border: 1px solid rgba(255,255,255,.28);\n  background: rgba(10,16,30,.45);\n  backdrop-filter: blur(6px);\n  touch-action: none;\n  -webkit-tap-highlight-color: transparent;\n  display: grid; place-items: center;\n}\n#t-nub {\n  width: 54px; height: 54px;\n  border-radius: 50%;\n  background: rgba(255,255,255,.28);\n  border: 1px solid rgba(255,255,255,.4);\n  pointer-events: none;\n}\n#t-action {\n  pointer-events: auto;\n  position: absolute; right: 22px; bottom: 24px;\n  width: 108px; height: 108px;\n  border-radius: 50%;\n  border: 1px solid rgba(255,75,58,.65);\n  background: rgba(255,75,58,.35);\n  color: #fff;\n  font-family: 'Space Grotesk', sans-serif;\n  font-weight: 700;\n  font-size: 0.72rem;\n  letter-spacing: 2px;\n  backdrop-filter: blur(6px);\n  touch-action: none;\n  -webkit-tap-highlight-color: transparent;\n}\n#t-action:active { background: rgba(255,75,58,.6); transform: scale(.93); }\n#touch-ui { display: none; }\nbody.touch #touch-ui { display: block; }\n.touch-only { display: none; }\nbody.touch .touch-only { display: block; }\nbody.touch #meters { bottom: 158px; width: min(320px, 50vw); }\n\n/* ---------- small screens ---------- */\n@media (max-width: 680px) {\n  #hud-top { top: 10px; left: 10px; right: 10px; }\n  #score-chip { padding: 6px 12px 8px; }\n  #score-line { font-size: 1rem; gap: 7px; }\n  #half-chip { font-size: 0.55rem; }\n  #timer { font-size: 1.5rem; padding: 4px 12px; }\n  .card-box, .title-box { padding: 24px 20px; max-width: 94vw; }\n  .card-box h2 { font-size: 1.6rem; }\n  .res-row { padding: 5px 10px; margin-bottom: 4px; font-size: 0.78rem; gap: 8px; grid-template-columns: 36px 1fr auto; }\n  .res-pos { font-size: 0.9rem; }\n}\n"}],"folders":[]},{"folder":"js","files":[{"name":"ai.js","content":"// ai.js — team brains: formation shape, chasing, passing, shooting, keepers.\n// Every player gets a.tv (target velocity) each frame; main.js integrates.\nimport { THREE, clamp } from './lib.js';\nimport { TEAMS, game, PITCH, targetGoalZ, ownGoalZ } from './core.js';\nimport { ball, BALL_R } from './ball.js';\nimport { audio } from './audio.js';\n\nconst V = new THREE.Vector3();\n\nexport const SPEED = { gk: 6.2, df: 7.3, at: 7.7, human: 8.1 };\n\nexport function dist2D(ax, az, bx, bz) { return Math.hypot(ax - bx, az - bz); }\n\nexport function ballDist(a) { return dist2D(a.pos.x, a.pos.z, ball.pos.x, ball.pos.z); }\n\n// may this athlete touch the ball right now? (restart restriction)\nexport function mayTouch(a) {\n  return game.restrictT <= 0 || a.team.id === game.restrictTeam;\n}\n\nexport function hasBall(a) {\n  if (ball.owner === a) return true;\n  return !ball.owner && ballDist(a) < 1.4 && ball.pos.y < 1.2 && ball.groundSpeed() < 9 && !ball.held;\n}\n\n// closest outfielder of a team to the ball\nexport function nearestToBall(team) {\n  let best = null, bd = 1e9;\n  for (const a of team.athletes) {\n    if (a.role === 'gk') continue;\n    const d = ballDist(a);\n    if (d < bd) { bd = d; best = a; }\n  }\n  return best;\n}\n\n// ---------------- kicking helpers (shared by human + AI)\nexport function bestPassTarget(a) {\n  const dir = game.attackDir[a.team.id];\n  let best = null, bs = -1e9;\n  for (const m of a.team.athletes) {\n    if (m === a || m.role === 'gk') continue;\n    const d = dist2D(a.pos.x, a.pos.z, m.pos.x, m.pos.z);\n    if (d < 3 || d > 34) continue;\n    let score = 20 - Math.abs(d - 14) * 0.6;\n    score += (m.pos.z - a.pos.z) * dir * 1.4; // forward is better\n    // interception risk: opponents near the passing lane\n    const other = TEAMS[1 - a.team.id];\n    const lx = m.pos.x - a.pos.x, lz = m.pos.z - a.pos.z;\n    const ll = Math.hypot(lx, lz);\n    for (const o of other.athletes) {\n      const t = clamp(((o.pos.x - a.pos.x) * lx + (o.pos.z - a.pos.z) * lz) / (ll * ll), 0, 1);\n      const px = a.pos.x + lx * t, pz = a.pos.z + lz * t;\n      const od = dist2D(o.pos.x, o.pos.z, px, pz);\n      if (od < 2.2) score -= (2.2 - od) * 9;\n    }\n    if (score > bs) { bs = score; best = m; }\n  }\n  return best;\n}\n\nexport function doPass(a) {\n  const m = bestPassTarget(a);\n  a.kickT = 0.26;\n  if (!m) { doClear(a); return null; }\n  // lead the runner a touch\n  const tx = m.pos.x + m.vel.x * 0.35;\n  const tz = m.pos.z + m.vel.z * 0.35;\n  const d = dist2D(ball.pos.x, ball.pos.z, tx, tz);\n  ball.kick(tx - ball.pos.x, tz - ball.pos.z, clamp(d * 1.35, 10, 20), d > 20 ? 3.2 : 0.4);\n  game.lastTouch = a;\n  audio.kick(0.45);\n  return m;\n}\n\nexport function doShoot(a, power = 0.7) {\n  a.kickT = 0.3;\n  const gz = targetGoalZ(a.team);\n  const keeper = TEAMS[1 - a.team.id].athletes[0];\n  // aim for the half of the goal the keeper isn't in, with a little scatter\n  let aimX = (keeper.pos.x > 0 ? -1 : 1) * PITCH.goalHW * (0.35 + Math.random() * 0.4);\n  aimX += (Math.random() - 0.5) * 2.4 * (1.15 - power);\n  const dx = aimX - ball.pos.x, dz = gz - ball.pos.z;\n  const d = Math.hypot(dx, dz);\n  ball.kick(dx, dz, 15 + power * 12, clamp(1 + d * 0.08 + power * 2.2, 1, 5));\n  game.lastTouch = a;\n  audio.kick(0.85 + power * 0.15);\n  return d;\n}\n\nexport function doClear(a) {\n  a.kickT = 0.28;\n  const dir = game.attackDir[a.team.id];\n  ball.kick((Math.random() - 0.5) * 8, dir * 20, 17 + Math.random() * 5, 4.5);\n  game.lastTouch = a;\n  audio.kick(0.8);\n}\n\n// ---------------- per-frame AI\nexport function updateAI(dt) {\n  const playing = game.state === 'play';\n  for (const team of TEAMS) {\n    const dir = game.attackDir[team.id];\n    const chaser = nearestToBall(team);\n    for (const a of team.athletes) {\n      if (a === game.controlled && game.humanActive) continue; // human is steering\n      a.decideT = (a.decideT || 0) - dt;\n      if (a.stunT > 0 || a.tackleT > 0) continue; // main handles those\n      if (!playing) { a.tv.set(0, 0, 0); continue; }\n\n      if (a.role === 'gk') { keeperBrain(a, dir, dt); continue; }\n\n      const bd = ballDist(a);\n\n      // ---- on the ball (we own it): dribble / pass / shoot\n      if (ball.owner === a) {\n        const gz = targetGoalZ(a.team);\n        const goalD = dist2D(a.pos.x, a.pos.z, 0, gz);\n        const opp = TEAMS[1 - team.id];\n        let pressD = 1e9;\n        for (const o of opp.athletes) pressD = Math.min(pressD, dist2D(a.pos.x, a.pos.z, o.pos.x, o.pos.z));\n\n        if (a.decideT <= 0) {\n          a.decideT = 0.18;\n          if (goalD < 16 + Math.random() * 5) {\n            doShoot(a, 0.6 + Math.random() * 0.35);\n            continue;\n          }\n          if (pressD < 2.4 && Math.random() < 0.8) {\n            const own = Math.abs(a.pos.z - ownGoalZ(a.team)) < 14;\n            if (own && Math.random() < 0.4) doClear(a);\n            else doPass(a);\n            continue;\n          }\n        }\n        // dribble toward goal, weaving off the press\n        const wx = Math.sin(game.time * 2.1 + a.phase) * 3.5;\n        V.set(clamp(0 + wx, -PITCH.hw + 3, PITCH.hw - 3) - a.pos.x, 0, gz - a.pos.z).normalize();\n        a.tv.set(V.x, 0, V.z).multiplyScalar(SPEED[a.role] * 0.92);\n        continue;\n      }\n\n      // ---- chasing the ball\n      const shouldChase = a === chaser && !ball.held;\n      if (shouldChase && mayTouch(a)) {\n        const lead = clamp(bd * 0.12, 0, 0.5);\n        const tx = ball.pos.x + ball.vel.x * lead;\n        const tz = ball.pos.z + ball.vel.z * lead;\n        V.set(tx - a.pos.x, 0, tz - a.pos.z);\n        const d = V.length() || 1;\n        a.tv.copy(V.multiplyScalar(SPEED[a.role] / d));\n        continue;\n      }\n\n      // ---- hold formation, sliding with the ball\n      const weHaveIt = game.lastTouch && game.lastTouch.team === team;\n      const push = weHaveIt ? dir * -3.5 : 0; // note: homeZ already encodes defence(+)\n      let hz = ball.pos.z * 0.55 - dir * a.homeZ + dir * (weHaveIt ? 6 : 0);\n      hz = clamp(hz, -PITCH.hl + 3, PITCH.hl - 3);\n      const hx = clamp(a.homeX + ball.pos.x * 0.25, -PITCH.hw + 2, PITCH.hw - 2);\n      V.set(hx - a.pos.x, 0, hz - a.pos.z);\n      const d = V.length();\n      if (d < 0.6) a.tv.set(0, 0, 0);\n      else a.tv.copy(V.multiplyScalar(Math.min(SPEED[a.role] * 0.85, d * 2.2) / d));\n    }\n  }\n}\n\nfunction keeperBrain(a, dir, dt) {\n  const gz = ownGoalZ(a.team);\n  const lineZ = gz - dir * -1 * 0; // stands on the line\n  a.diveT = Math.max(0, (a.diveT || 0) - dt);\n\n  // holding the ball: countdown then punt upfield\n  if (ball.held === a) {\n    a.holdT = (a.holdT || 1.1) - dt;\n    a.tv.set(0, 0, 0);\n    if (a.holdT <= 0) {\n      a.holdT = null;\n      ball.held = null;\n      const mates = a.team.athletes.filter((m) => m.role === 'at');\n      const m = mates[Math.floor(Math.random() * mates.length)];\n      a.kickT = 0.3;\n      ball.pos.y = 1.0;\n      ball.kick(m.pos.x - a.pos.x, m.pos.z - a.pos.z, 19, 5.5);\n      game.lastTouch = a;\n      audio.kick(0.9);\n    }\n    return;\n  }\n  if (a.holdT != null) a.holdT = null;\n\n  const bd = ballDist(a);\n  const towardUs = ball.vel.z * dir < -6; // ball travelling at our goal\n  const distToLine = Math.abs(ball.pos.z - gz);\n\n  // dive: shot incoming, close, predicted inside the frame\n  if (a.diveT <= 0 && towardUs && distToLine < 11 && ball.groundSpeed() > 11) {\n    const t = distToLine / Math.abs(ball.vel.z);\n    const px = ball.pos.x + ball.vel.x * t;\n    if (Math.abs(px) < PITCH.goalHW + 1.2 && t < 0.75) {\n      a.diveT = 0.65;\n      a.diveDir = Math.sign(px - a.pos.x) || 1;\n      V.set(px - a.pos.x, 0, ball.pos.z + ball.vel.z * t * 0.5 - a.pos.z).normalize();\n      a.tv.copy(V).multiplyScalar(10.5);\n      return;\n    }\n  }\n  if (a.diveT > 0) return; // keep lunging with current tv\n\n  // sweep up a loose ball in the box\n  if (bd < 7 && !ball.held && ball.groundSpeed() < 10 && mayTouch(a)) {\n    V.set(ball.pos.x - a.pos.x, 0, ball.pos.z - a.pos.z);\n    const d = V.length() || 1;\n    a.tv.copy(V.multiplyScalar(SPEED.gk / d));\n    return;\n  }\n\n  // shuffle along the line, tracking the ball\n  const tx = clamp(ball.pos.x * 0.55, -PITCH.goalHW + 0.5, PITCH.goalHW - 0.5);\n  const tz = gz + dir * 0.9;\n  V.set(tx - a.pos.x, 0, tz - a.pos.z);\n  const d = V.length();\n  if (d < 0.3) a.tv.set(0, 0, 0);\n  else a.tv.copy(V.multiplyScalar(Math.min(SPEED.gk, d * 3) / d));\n}\n"},{"name":"athlete.js","content":"// athlete.js — Sensible-Soccer-proportioned voxel footballer: 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    // squad number printed straight on the back of the shirt\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.font = '900 46px \"Archivo Black\", sans-serif';\n      bctx.textAlign = 'center';\n      bctx.textBaseline = 'middle';\n      bctx.lineWidth = 8;\n      bctx.lineJoin = 'round';\n      bctx.strokeStyle = 'rgba(10,14,24,0.85)';\n      bctx.strokeText(String(num), 32, 36);\n      bctx.fillStyle = '#f5f1e6';\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(1.0, 1.0),\n        new THREE.MeshStandardMaterial({ map: bibTex, roughness: 0.9, transparent: true })\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 | kick | tackle | dive | air | crouch | celebrate |\n  //       dejected | exhausted | charge\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') {\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      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      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 === 'kick') {\n      // right-foot strike: backswing already happened, leg whips through\n      const k = Math.min(1, mt / 0.16);\n      P.hipR = -1.35 + k * 2.6;\n      P.kneeR = Math.max(0.1, 1.25 - k * 1.15);\n      P.hipL = 0.3 - k * 0.4;\n      P.kneeL = 0.4;\n      P.shL = -1.3 + k * 0.9;\n      P.shR = 0.9 - k * 1.5;\n      P.armZL = 0.3; P.armZR = -0.3;\n      lean = 0.16;\n      P.hx = -0.22;\n      P.bobY = -0.14;\n      P.browA = 0.35;\n    } else if (mode === 'tackle') {\n      // sliding on his backside, feet-first, arms thrown\n      P.bobY = -1.55;\n      P.bobZ = 0.25;\n      lean = -1.05;\n      P.hipL = 1.15; P.kneeL = 0.15;\n      P.hipR = 0.65; P.kneeR = 0.95;\n      P.shL = -0.4; P.shR = -2.3;\n      P.elL = -0.9; P.elR = -0.25;\n      P.armZL = 0.25; P.armZR = 0.35;\n      P.hx = 0.6;\n      P.browA = 0.4;\n    } else if (mode === 'dive') {\n      // keeper full-stretch — direction (left/right) via opt.dir\n      const d = opt.dir || 1;\n      P.roll = -d * 1.4;\n      P.bobY = -0.5;\n      P.shL = -2.7; P.shR = -2.7;\n      P.elL = -0.15; P.elR = -0.15;\n      P.armZL = -0.3; P.armZR = 0.3;\n      P.hipL = 0.35; P.hipR = -0.25;\n      P.kneeL = 0.55; P.kneeR = 0.3;\n      P.hz = -d * 0.3;\n      P.browY = 0.1;\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      // keeper set position: low, wide, arms ready\n      P.bobY = -0.55;\n      P.hipL = 0.75; P.kneeL = 1.0;\n      P.hipR = 0.75; P.kneeR = 1.0;\n      P.shL = -0.85; P.shR = -0.85;\n      P.elL = -1.1; P.elR = -1.1;\n      P.armZL = 0.55; P.armZR = -0.55;\n      lean = 0.28;\n      P.hx = -0.3;\n      P.sy = 0.94; P.sxz = 1.05;\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      // winding up a big shot: planted, coiled\n      P.bobY = -0.3;\n      P.hipL = 0.55; P.kneeL = 0.75;\n      P.hipR = -0.9; P.kneeR = 0.9;\n      P.shL = -0.7; P.elL = -0.5;\n      P.shR = 0.7; P.elR = -0.4;\n      lean = 0.1;\n      P.hx = -0.3;\n      P.sy = 0.95; P.sxz = 1.03;\n      P.browA = 0.35;\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 === 'kick' || mode === 'tackle' || mode === 'dive' || 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 ground, more frequent as excitement rises\n//  - cheers on a dedicated swell channel, referee whistle, ball thumps\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    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  // ball struck: thump body + leathery snap, volume scales with power\n  kick(p = 0.5) {\n    if (!this.ctx) return;\n    this._noiseHit({ buf: 'brown', dur: 0.09, type: 'lowpass', freq: 300 + p * 250, vol: 0.18 + p * 0.25, pan: 0, attack: 0.003 });\n    this.beep(70 + p * 40, 0.09, 'sine', 0.16 + p * 0.2);\n  },\n\n  bounce() {\n    this._noiseHit({ buf: 'brown', dur: 0.05, type: 'lowpass', freq: 260, vol: 0.07, pan: 0, attack: 0.003 });\n  },\n\n  post() {\n    this.beep(1180, 0.4, 'sine', 0.2);\n    this.beep(590, 0.3, 'triangle', 0.12);\n  },\n\n  // referee whistle: n blasts (1 long for goals/half, 2 short for restarts)\n  ref(n = 1, long = false) {\n    if (!this.ctx) return;\n    for (let i = 0; i < n; i++) {\n      setTimeout(() => {\n        const t = this.ctx.currentTime;\n        const dur = long ? 0.75 : 0.22;\n        const o = this.ctx.createOscillator();\n        o.type = 'square'; o.frequency.value = 2350;\n        const trill = this.ctx.createOscillator();\n        trill.frequency.value = 38;\n        const trillAmt = this.ctx.createGain();\n        trillAmt.gain.value = 160;\n        trill.connect(trillAmt).connect(o.frequency);\n        const g = this.ctx.createGain();\n        g.gain.setValueAtTime(0.0001, t);\n        g.gain.linearRampToValueAtTime(0.11, t + 0.02);\n        g.gain.setValueAtTime(0.11, t + dur - 0.04);\n        g.gain.exponentialRampToValueAtTime(0.0001, t + dur);\n        o.connect(g).connect(this.master);\n        o.start(); trill.start();\n        o.stop(t + dur + 0.05); trill.stop(t + dur + 0.05);\n      }, i * (long ? 900 : 320));\n    }\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    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  // the big one — roar + horns + a snatch of fanfare\n  goal() {\n    this.cheer();\n    setTimeout(() => this.cheer(), 900);\n    this.horn();\n    setTimeout(() => this.horn(), 500);\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.18);\n        this.beep(f / 2, dur, 'triangle', 0.12);\n      }, at * 1000 + 350);\n    }\n  },\n\n  groan() {\n    if (!this.ctx || !this.cheerGain) return;\n    // the crowd deflates: low swell that sags\n    this._noiseHit({ buf: 'brown', dur: 1.1, type: 'lowpass', freq: 300, vol: 0.16, pan: 0, attack: 0.12 });\n    for (let i = 0; i < 3; i++) {\n      setTimeout(() => {\n        const o = this.ctx.createOscillator();\n        o.type = 'triangle';\n        const t = this.ctx.currentTime;\n        const f0 = 260 + Math.random() * 80;\n        o.frequency.setValueAtTime(f0, t);\n        o.frequency.exponentialRampToValueAtTime(f0 * 0.6, t + 0.5);\n        const g = this.ctx.createGain();\n        g.gain.setValueAtTime(0.0001, t);\n        g.gain.linearRampToValueAtTime(0.05, t + 0.08);\n        g.gain.exponentialRampToValueAtTime(0.0001, t + 0.55);\n        o.connect(g).connect(this._out());\n        o.start(); o.stop(t + 0.6);\n      }, i * 90);\n    }\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 turf — 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.05, pan: 0, attack: 0.003 });\n    this.beep(85 + Math.random() * 25, 0.05, 'sine', 0.045);\n  },\n\n  // ---- crowd one-shots, panned around the ground\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  whistleCrowd() {\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  // a terrace chant: rhythmic claps + a rough four-note terrace melody\n  chant() {\n    if (!this.ctx) return;\n    const beat = 280;\n    for (let i = 0; i < 8; i++) {\n      setTimeout(() => this.clapBurst(3), i * beat);\n    }\n    const line = [262, 262, 294, 330, 294, 262];\n    line.forEach((f, i) => {\n      setTimeout(() => {\n        this._noiseHit({ dur: 0.22, type: 'bandpass', freq: f * 2, q: 4, vol: 0.06, attack: 0.03 });\n        this.beep(f, 0.2, 'triangle', 0.04);\n      }, i * beat + 100);\n    });\n  },\n\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.5 && r < 0.05) this.horn();\n    else if (r < 0.1 && excitement > 0.35) this.chant();\n    else if (r < 0.42) this.whoop();\n    else if (r < 0.72) this.shout();\n    else if (r < 0.83) this.whistleCrowd();\n    else this.clapBurst(3 + Math.floor(Math.random() * 3 + excitement * 4));\n  },\n};\n"},{"name":"ball.js","content":"// ball.js — the voxel match ball: white core with black patches, arcade physics\nimport { THREE, boxMesh } from './lib.js';\nimport { scene, PITCH } from './core.js';\n\nexport const BALL_R = 0.36;\n\nconst group = new THREE.Group();\nconst core = boxMesh(0.62, 0.62, 0.62, '#f6f4ee', { roughness: 0.55 });\ngroup.add(core);\nfor (const [x, y, z] of [[0.26, 0.26, 0.26], [-0.26, -0.26, 0.26], [0.26, -0.26, -0.26], [-0.26, 0.26, -0.26], [0.3, 0, 0], [0, 0.3, 0]]) {\n  const patch = boxMesh(0.24, 0.24, 0.24, '#1b1f26', { roughness: 0.6 });\n  patch.position.set(x, y, z);\n  group.add(patch);\n}\nscene.add(group);\n\nexport const ball = {\n  group,\n  pos: new THREE.Vector3(0, BALL_R, 0),\n  vel: new THREE.Vector3(),\n  spin: new THREE.Vector3(),\n  held: null,     // keeper holding it\n  owner: null,    // dribbler the ball is glued to\n  noStickT: 0,    // just kicked — don't re-stick immediately\n\n  reset(x = 0, z = 0) {\n    this.pos.set(x, BALL_R, z);\n    this.vel.set(0, 0, 0);\n    this.held = null;\n    this.owner = null;\n    this.noStickT = 0;\n  },\n\n  kick(dx, dz, speed, vy = 0) {\n    const len = Math.hypot(dx, dz) || 1;\n    this.vel.set((dx / len) * speed, vy, (dz / len) * speed);\n    this.held = null;\n    this.owner = null;\n    this.noStickT = 0.3;\n  },\n\n  update(dt) {\n    this.noStickT = Math.max(0, this.noStickT - dt);\n    // close control: the ball rides a stiff spring to a point just ahead\n    // of the owner's boots, so it genuinely sticks while dribbling\n    if (this.owner && !this.held) {\n      const o = this.owner;\n      const lead = 0.55 + o.speed * 0.085;\n      const fx = o.pos.x + Math.cos(o.heading) * lead;\n      const fz = o.pos.z + Math.sin(o.heading) * lead;\n      this.vel.x = o.vel.x + (fx - this.pos.x) * 13;\n      this.vel.z = o.vel.z + (fz - this.pos.z) * 13;\n      const sp = Math.hypot(this.vel.x, this.vel.z);\n      const cap = Math.max(o.speed + 4.5, 6);\n      if (sp > cap) { this.vel.x *= cap / sp; this.vel.z *= cap / sp; }\n    }\n    if (this.held) {\n      // glued to the keeper's chest\n      const h = this.held;\n      this.pos.set(\n        h.pos.x + Math.cos(h.heading) * 0.5,\n        1.05,\n        h.pos.z + Math.sin(h.heading) * 0.5\n      );\n      this.vel.set(0, 0, 0);\n    } else {\n      this.vel.y -= 22 * dt; // heavy arcade gravity\n      this.pos.addScaledVector(this.vel, dt);\n\n      // bounce + roll friction\n      if (this.pos.y < BALL_R) {\n        this.pos.y = BALL_R;\n        if (this.vel.y < -1.2) this.vel.y = -this.vel.y * 0.52;\n        else this.vel.y = 0;\n        // ground friction\n        const f = Math.max(0, 1 - dt * 1.35);\n        this.vel.x *= f;\n        this.vel.z *= f;\n      } else {\n        // light air drag\n        const f = Math.max(0, 1 - dt * 0.12);\n        this.vel.x *= f;\n        this.vel.z *= f;\n      }\n\n      // goal frame: posts + crossbar are solid-ish (cheap sphere-vs-post)\n      for (const e of [-1, 1]) {\n        const gl = PITCH.hl * e;\n        if (Math.abs(this.pos.z - gl) < 0.5) {\n          for (const s of [-1, 1]) {\n            const px = s * PITCH.goalHW;\n            if (Math.abs(this.pos.x - px) < 0.45 && this.pos.y < PITCH.goalH + 0.3) {\n              this.vel.x *= -0.5;\n              this.pos.x = px + Math.sign(this.pos.x - px) * 0.46;\n              if (this.groundSpeed() > 6) this.hitPost = true;\n            }\n          }\n          if (Math.abs(this.pos.y - PITCH.goalH) < 0.35 && Math.abs(this.pos.x) < PITCH.goalHW + 0.3 && this.vel.y > 0) {\n            this.vel.y *= -0.5;\n            if (this.groundSpeed() > 6) this.hitPost = true;\n          }\n        }\n        // net catches the ball\n        if (e * this.pos.z > gl + 0.4 && e * this.pos.z < gl + 2.1 && Math.abs(this.pos.x) < PITCH.goalHW + 0.2 && this.pos.y < 2.4) {\n          this.vel.multiplyScalar(Math.max(0, 1 - dt * 12));\n          if (e * this.pos.z > gl + 1.6) this.pos.z = (gl + 1.6) * e;\n        }\n      }\n    }\n\n    group.position.copy(this.pos);\n    // roll: rotate against travel\n    group.rotation.x += this.vel.z * dt / BALL_R;\n    group.rotation.z -= this.vel.x * dt / BALL_R;\n  },\n\n  groundSpeed() { return Math.hypot(this.vel.x, this.vel.z); },\n};\n"},{"name":"core.js","content":"// core.js — renderer, scene, camera, teams, shared match state\nimport { THREE, clamp } from './lib.js';\nimport { buildStadium, PITCH, 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, 150, 170);\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\n// ---------------- the two squads: 1 keeper, 2 defenders, 2 attackers each\nexport const TEAMS = [\n  {\n    id: 0, name: 'VOXEL UTD', short: 'VOX', color: '#ff4b3a', human: true,\n    players: [\n      { name: 'SAFE HANDS', role: 'gk', kit: '#ffb020', shorts: '#111827', skin: '#e8b48c', hair: '#c9c9c9', num: 1 },\n      { name: 'TERRY TACKLE', role: 'df', hx: -7, hz: 15, kit: '#ff4b3a', shorts: '#ffffff', skin: '#8a5a33', hair: '#101010', num: 2 },\n      { name: 'BRICK WALL', role: 'df', hx: 7, hz: 15, kit: '#ff4b3a', shorts: '#ffffff', skin: '#f0c8a0', hair: '#5b3a1e', hairStyle: 'band', num: 5 },\n      { name: 'DAVEY BOOT', role: 'at', hx: -5, hz: -4, kit: '#ff4b3a', shorts: '#ffffff', skin: '#c98e5a', hair: '#20140e', hairStyle: 'spike', num: 7 },\n      { name: 'RIO VOXEL', role: 'at', hx: 5, hz: -4, kit: '#ff4b3a', shorts: '#ffffff', skin: '#6b4226', hair: '#101010', hairStyle: 'afro', num: 9 },\n    ],\n  },\n  {\n    id: 1, name: 'DYNAMO BLU', short: 'DYN', color: '#2563eb', human: false,\n    players: [\n      { name: 'IRON GLOVE', role: 'gk', kit: '#10b981', shorts: '#111827', skin: '#c98e5a', hair: '#3b2b1a', num: 1 },\n      { name: 'COLD STEEL', role: 'df', hx: -7, hz: 15, kit: '#2563eb', shorts: '#111827', skin: '#f0c8a0', hair: '#7a3b12', num: 4 },\n      { name: 'BIG UNIT', role: 'df', hx: 7, hz: 15, kit: '#2563eb', shorts: '#111827', skin: '#8a5a33', hair: '#101010', hairStyle: 'afro', num: 6 },\n      { name: 'SILKY S.', role: 'at', hx: -5, hz: -4, kit: '#2563eb', shorts: '#111827', skin: '#e8b48c', hair: '#c9c9c9', hairStyle: 'pony', num: 10 },\n      { name: 'TURBO T.', role: 'at', hx: 5, hz: -4, kit: '#2563eb', shorts: '#111827', skin: '#6b4226', hair: '#20140e', hairStyle: 'spike', num: 11 },\n    ],\n  },\n];\n\n// build athletes; each gets sim fields bolted on\nexport const players = [];\nfor (const team of TEAMS) {\n  team.athletes = team.players.map((p) => {\n    const a = new Athlete(p);\n    a.team = team;\n    a.role = p.role;\n    a.homeX = p.hx || 0;\n    a.homeZ = p.hz || 0;\n    a.pos = new THREE.Vector3();\n    a.vel = new THREE.Vector3();\n    a.heading = 0;           // radians, atan2(dz,dx) style direction of travel\n    a.speed = 0;\n    a.mode = 'idle';\n    a.modeOpt = {};\n    a.kickT = 0;             // time left in kick animation\n    a.tackleT = 0;           // time left sliding\n    a.tackleCd = 0;\n    a.diveT = 0;\n    a.stunT = 0;             // knocked over / recovering\n    scene.add(a.group);\n    players.push(a);\n    return a;\n  });\n}\n\nexport const gks = [TEAMS[0].athletes[0], TEAMS[1].athletes[0]];\n\n// ---------------- shared match state\nexport const game = {\n  state: 'title',   // title | kickoff | play | goal | half | full\n  half: 1,\n  clock: 0,          // real seconds elapsed this half\n  HALF_LEN: 120,     // real seconds per half (displayed as 45:00)\n  score: [0, 0],\n  goals: [],         // {team, name, min}\n  lastTouch: null,   // athlete who touched last\n  restrictTeam: -1,  // only this team may take the first touch (restarts)\n  restrictT: 0,\n  controlled: null,  // the athlete the human is driving\n  timeScale: 1, tsTarget: 1, tsTimer: 0,\n  excitement: 0.25,\n  shake: 0,\n  time: 0,\n  attackDir: [-1, 1], // team 0 attacks -z in the first half\n  stateT: 0,\n};\n\nexport const cam = {\n  pos: camera.position.clone(),\n  look: CENTER.clone(),\n  mode: 'orbit',\n  orbitAngle: 0.6,\n  focus: null, // athlete to orbit during goal celebrations\n};\n\nexport function setSlowmo(scale, realSeconds) {\n  game.tsTarget = scale;\n  game.tsTimer = realSeconds;\n}\n\nexport function ownGoalZ(team) { return -game.attackDir[team.id] * PITCH.hl; }\nexport function targetGoalZ(team) { return game.attackDir[team.id] * PITCH.hl; }\n\nexport { clamp, PITCH };\n"},{"name":"fx.js","content":"// fx.js — instanced particle system (confetti, turf, chalk dust)\nimport { THREE } from './lib.js';\nimport { scene } from './core.js';\n\nexport const CONFETTI = ['#ff4b3a', '#ffb020', '#4aa3ff', '#7ce34a', '#f5f1e6', '#c084fc'];\nexport const TURF = ['#5fae3f', '#4b9231', '#7a5c3a'];\nexport const CHALK = ['#f3efe2', '#e8e4d4', '#ffffff'];\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":"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'), halfChip: $('half-chip'),\n  scoreHome: $('score-home'), scoreAway: $('score-away'),\n  scoreHomeName: $('score-home-name'), scoreAwayName: $('score-away-name'),\n  msg: $('center-msg'), popup: $('popup'),\n  powerWrap: $('power-wrap'), powerFill: $('power-fill'), powerLabel: $('power-label'),\n  card: $('card'), cardNo: $('card-no'), cardName: $('card-name'), cardDesc: $('card-desc'),\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, show, label) {\n  hud.powerWrap.classList.toggle('hidden', !show);\n  hud.powerFill.style.width = (clamp01(v01) * 100).toFixed(1) + '%';\n  if (label) hud.powerLabel.textContent = label;\n}\n\nexport function setScore(s0, s1) {\n  hud.scoreHome.textContent = s0;\n  hud.scoreAway.textContent = s1;\n}\n\nexport function setClock(text) { hud.timer.textContent = text; }\nexport function setHalf(text) { hud.halfChip.textContent = text; }\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, matOpts = null } = {}) {\n  const mat = new THREE.MeshStandardMaterial(Object.assign({ roughness: 0.95, metalness: 0 }, matOpts || {}));\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 Football: match flow, input, camera, render loop\nimport { THREE, clamp } from './lib.js';\nimport {\n  renderer, scene, camera, game, cam, stadium, TEAMS, players, gks,\n  setSlowmo, IS_TOUCH, PITCH, targetGoalZ, ownGoalZ,\n} from './core.js';\nimport { CENTER, headingFor } from './world.js';\nimport { ball, BALL_R } from './ball.js';\nimport * as hud from './hud.js';\nimport { audio } from './audio.js';\nimport { spawnParticles, updateParticles, CONFETTI, TURF } from './fx.js';\nimport {\n  updateAI, doPass, doShoot, ballDist, mayTouch, hasBall, dist2D, SPEED,\n} from './ai.js';\n\nconst HUMAN = TEAMS[0];\nconst AWAY = TEAMS[1];\nfor (const a of players) a.tv = new THREE.Vector3();\n\nhud.hud.scoreHomeName.textContent = HUMAN.short;\nhud.hud.scoreAwayName.textContent = AWAY.short;\n\n// ---------------------------------------------------------------- \"YOU\" marker\nconst markerCanvas = document.createElement('canvas');\nmarkerCanvas.width = 256; markerCanvas.height = 128;\nconst mctx = markerCanvas.getContext('2d');\nmctx.font = '900 74px \"Archivo Black\", sans-serif';\nmctx.textAlign = 'center';\nmctx.textBaseline = 'middle';\nmctx.lineWidth = 16;\nmctx.lineJoin = 'round';\nmctx.strokeStyle = 'rgba(10, 16, 30, 0.92)';\nmctx.strokeText('YOU', 128, 66);\nmctx.fillStyle = '#ffd24a';\nmctx.fillText('YOU', 128, 66);\nconst markerTex = new THREE.CanvasTexture(markerCanvas);\nmarkerTex.colorSpace = THREE.SRGBColorSpace;\nconst marker = new THREE.Sprite(new THREE.SpriteMaterial({ map: markerTex, transparent: true, opacity: 0.95 }));\nmarker.scale.set(1.8, 0.9, 1);\nmarker.visible = false;\nscene.add(marker);\n\n// ---------------------------------------------------------------- input\nconst keys = {};\nlet chargeT = -1;        // >= 0 while SPACE is held\nlet enterQueued = false;\n\nwindow.addEventListener('keydown', (e) => {\n  if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) e.preventDefault();\n  audio.ensure();\n  const k = e.key.toLowerCase();\n  keys[k] = true;\n  if (e.key === 'Enter') enterQueued = true;\n  if (e.key === ' ' && chargeT < 0 && !e.repeat) chargeT = 0;\n});\nwindow.addEventListener('keyup', (e) => {\n  const k = e.key.toLowerCase();\n  keys[k] = false;\n  if (e.key === ' ' && chargeT >= 0) { playerAction(chargeT); chargeT = -1; }\n});\n\nfunction inputVec() {\n  let x = 0, z = 0;\n  if (keys['arrowleft'] || keys['a']) x -= 1;\n  if (keys['arrowright'] || keys['d']) x += 1;\n  if (keys['arrowup'] || keys['w']) z -= 1;\n  if (keys['arrowdown'] || keys['s']) z += 1;\n  if (stick.active) { x = stick.x; z = stick.z; }\n  const l = Math.hypot(x, z);\n  return l > 1 ? { x: x / l, z: z / l } : { x, z };\n}\n\n// touch: drag-stick on the left, action button on the right\nconst stick = { active: false, x: 0, z: 0, ox: 0, oy: 0 };\nif (IS_TOUCH) document.body.classList.add('touch');\nconst stickEl = document.getElementById('t-stick');\nconst nubEl = document.getElementById('t-nub');\nconst actEl = document.getElementById('t-action');\nif (stickEl) {\n  stickEl.addEventListener('touchstart', (e) => {\n    e.preventDefault(); audio.ensure();\n    const t = e.changedTouches[0];\n    stick.active = true; stick.ox = t.clientX; stick.oy = t.clientY;\n  }, { passive: false });\n  stickEl.addEventListener('touchmove', (e) => {\n    e.preventDefault();\n    const t = e.changedTouches[0];\n    const dx = t.clientX - stick.ox, dy = t.clientY - stick.oy;\n    const l = Math.hypot(dx, dy);\n    const k = l > 40 ? 40 / l : 1;\n    stick.x = (dx * k) / 40; stick.z = (dy * k) / 40;\n    nubEl.style.transform = `translate(${dx * k}px, ${dy * k}px)`;\n  }, { passive: false });\n  const end = (e) => {\n    e.preventDefault();\n    stick.active = false; stick.x = 0; stick.z = 0;\n    nubEl.style.transform = '';\n  };\n  stickEl.addEventListener('touchend', end, { passive: false });\n  stickEl.addEventListener('touchcancel', end, { passive: false });\n}\nif (actEl) {\n  actEl.addEventListener('touchstart', (e) => {\n    e.preventDefault(); audio.ensure();\n    if (game.state === 'title' || game.state === 'half' || game.state === 'full') { enterQueued = true; return; }\n    if (chargeT < 0) chargeT = 0;\n  }, { passive: false });\n  actEl.addEventListener('touchend', (e) => {\n    e.preventDefault();\n    if (chargeT >= 0) { playerAction(chargeT); chargeT = -1; }\n  }, { passive: false });\n}\nwindow.addEventListener('pointerdown', () => audio.ensure(), { once: true });\n\n// ---------------------------------------------------------------- flow\nfunction fmtClock() {\n  const scaled = clamp(game.clock / game.HALF_LEN, 0, 1) * 45 * 60 + (game.half === 2 ? 45 * 60 : 0);\n  const m = Math.floor(scaled / 60), s = Math.floor(scaled % 60);\n  return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');\n}\n\nfunction updateScoreboardScreen(sub) {\n  stadium.scoreboard(`${HUMAN.short} ${game.score[0]}-${game.score[1]} ${AWAY.short}`, sub);\n}\n\nfunction setupKickoff(kickingId) {\n  ball.reset(0, 0);\n  game.restrictTeam = kickingId;\n  game.restrictT = 5;\n  game.kickingId = kickingId;\n  for (const team of TEAMS) {\n    const dir = game.attackDir[team.id];\n    const kicking = team.id === kickingId;\n    const ats = team.athletes.filter((a) => a.role === 'at');\n    const dfs = team.athletes.filter((a) => a.role === 'df');\n    const gk = team.athletes[0];\n    gk.pos.set(0, 0, -dir * (PITCH.hl - 0.8));\n    dfs[0].pos.set(-7, 0, -dir * 17);\n    dfs[1].pos.set(7, 0, -dir * 17);\n    if (kicking) {\n      ats[0].pos.set(0.5, 0, -dir * 1.1);\n      ats[1].pos.set(-5.5, 0, -dir * 4.5);\n    } else {\n      ats[0].pos.set(-4.5, 0, -dir * 8.5);\n      ats[1].pos.set(4.5, 0, -dir * 8.5);\n    }\n    for (const a of team.athletes) {\n      a.vel.set(0, 0, 0);\n      a.tv.set(0, 0, 0);\n      a.heading = Math.atan2(dir, 0);\n      a.kickT = 0; a.tackleT = 0; a.diveT = 0; a.stunT = 0;\n    }\n  }\n  game.controlled = null;\n  pickControlled(true);\n  game.state = 'kickoff';\n  game.stateT = 1.3;\n}\n\nfunction beginMatch() {\n  game.score = [0, 0];\n  game.goals = [];\n  game.half = 1;\n  game.clock = 0;\n  game.attackDir = [-1, 1];\n  hud.setScore(0, 0);\n  hud.setHalf('FIRST HALF');\n  updateScoreboardScreen('FIRST HALF');\n  hud.hud.title.classList.add('hidden');\n  hud.hud.results.classList.add('hidden');\n  hud.hud.root.classList.remove('hidden');\n  setupKickoff(0);\n  audio.ref(1);\n  hud.showMsg('KICK OFF', 'go');\n  cam.mode = 'follow';\n}\n\nfunction onGoal(scoringId) {\n  const conceding = 1 - scoringId;\n  game.score[scoringId]++;\n  hud.setScore(game.score[0], game.score[1]);\n  const scorer = game.lastTouch || TEAMS[scoringId].athletes[3];\n  const og = scorer.team.id !== scoringId;\n  const min = Math.max(1, Math.round((game.clock / game.HALF_LEN) * 45 + (game.half === 2 ? 45 : 0)));\n  game.goals.push({ team: scoringId, name: scorer.name + (og ? ' (OG)' : ''), min });\n  game.scorer = og ? TEAMS[scoringId].athletes[3 + Math.floor(Math.random() * 2)] : scorer;\n  game.state = 'goal';\n  game.stateT = 4.4;\n  for (const p of players) { p.tv.set(0, 0, 0); p.vel.multiplyScalar(0.2); }\n  game.concededId = conceding;\n  game.exciteBoost = 1;\n  game.shake = 0.7;\n  setSlowmo(0.35, 0.9);\n  hud.showMsg('GOAL!', scoringId === 0 ? 'go' : 'bad');\n  hud.showPopup(`${scorer.name} ${min}'`, scoringId !== 0);\n  audio.ref(1, true);\n  if (scoringId === 0) audio.goal(); else { audio.groan(); setTimeout(() => audio.clapBurst(4), 800); }\n  spawnParticles(ball.pos, CONFETTI, scoringId === 0 ? 90 : 25, { up: 9, vel: 8, g: 9, life: 2.2, spread: 3 });\n  updateScoreboardScreen(scoringId === 0 ? 'GOOOAL!' : 'OH NO...');\n  cam.mode = 'goalcam';\n  chargeT = -1;\n}\n\nfunction endHalf() {\n  if (game.half === 1) {\n    game.state = 'half';\n    audio.ref(1, true);\n    hud.showMsg('HALF TIME', '');\n    showResults('HALF TIME', 'PRESS ENTER FOR THE SECOND HALF');\n    updateScoreboardScreen('HALF TIME');\n  } else {\n    game.state = 'full';\n    audio.ref(3);\n    const win = game.score[0] > game.score[1];\n    const draw = game.score[0] === game.score[1];\n    hud.showMsg('FULL TIME', win ? 'go' : draw ? '' : 'bad');\n    showResults('FULL TIME', 'PRESS ENTER FOR A REMATCH');\n    updateScoreboardScreen(win ? `${HUMAN.short} WIN!` : draw ? 'A DRAW' : `${AWAY.short} WIN`);\n    if (win) { audio.goal(); game.exciteBoost = 1; }\n  }\n  chargeT = -1;\n  cam.mode = 'orbit';\n}\n\nfunction showResults(kicker, hint) {\n  hud.hud.resultsKicker.textContent = kicker;\n  hud.hud.resultsTitle.textContent = `${HUMAN.short} ${game.score[0]} — ${game.score[1]} ${AWAY.short}`;\n  let rows = '';\n  if (game.goals.length === 0) {\n    rows = '<div class=\"res-row\"><span class=\"res-pos\">—</span><span class=\"res-name\">NO GOALS YET</span><span class=\"res-mark\"></span></div>';\n  }\n  for (const g of game.goals) {\n    rows += `<div class=\"res-row ${g.team === 0 ? 'player' : ''}\">\n      <span class=\"res-pos\">${g.min}'</span>\n      <span class=\"res-name\">${g.name}</span>\n      <span class=\"res-mark\">${TEAMS[g.team].short}</span>\n    </div>`;\n  }\n  hud.hud.resultsTable.innerHTML = rows;\n  hud.hud.resultsHint.innerHTML = hint.replace('ENTER', '<b>ENTER</b>');\n  hud.hud.results.classList.remove('hidden');\n}\n\nfunction handleEnter() {\n  if (game.state === 'title') beginMatch();\n  else if (game.state === 'half') {\n    hud.hud.results.classList.add('hidden');\n    game.half = 2;\n    game.clock = 0;\n    game.attackDir = [1, -1];\n    hud.setHalf('SECOND HALF');\n    updateScoreboardScreen('SECOND HALF');\n    setupKickoff(1);\n    audio.ref(1);\n    hud.showMsg('KICK OFF', 'go');\n    cam.mode = 'follow';\n  } else if (game.state === 'full') {\n    hud.hud.results.classList.add('hidden');\n    hud.hud.root.classList.add('hidden');\n    hud.hud.title.classList.remove('hidden');\n    game.state = 'title';\n    cam.mode = 'orbit';\n    setupTitleScene();\n  }\n}\n\nfunction setupTitleScene() {\n  // players warm up in loose formation while the camera drifts\n  for (const team of TEAMS) {\n    const dir = team.id === 0 ? -1 : 1;\n    team.athletes.forEach((a, i) => {\n      a.pos.set(-8 + i * 4, 0, dir * (8 + (i % 2) * 5));\n      a.vel.set(0, 0, 0); a.tv.set(0, 0, 0);\n      a.heading = Math.atan2(-dir, 0);\n    });\n  }\n  ball.reset(0, 0);\n  game.state = 'title';\n}\n\n// ---------------------------------------------------------------- player control\nfunction pickControlled(force = false) {\n  let best = null, bd = 1e9;\n  for (const a of HUMAN.athletes) {\n    if (a.role === 'gk') continue;\n    const d = ballDist(a);\n    if (d < bd) { bd = d; best = a; }\n  }\n  const cur = game.controlled;\n  if (force || !cur || cur.role === 'gk') { game.controlled = best; return; }\n  if (best !== cur && bd < ballDist(cur) - 1.4) game.controlled = best;\n}\n\nfunction playerAction(charge) {\n  const a = game.controlled;\n  if (!a || (game.state !== 'play' && game.state !== 'kickoff')) return;\n  const near = (ball.owner === a || ballDist(a) < 1.8) && ball.pos.y < 1.6 && !ball.held;\n  if (near && mayTouch(a)) {\n    if (charge < 0.24) {\n      doPass(a);\n      hud.setPower(0, false);\n    } else {\n      const p = clamp(charge / 0.9, 0.3, 1);\n      doShoot(a, p);\n      game.shake = Math.max(game.shake, p * 0.25);\n      game.fovKick = p * 3.5;\n      hud.setPower(0, false);\n    }\n  } else if (a.tackleCd <= 0 && a.tackleT <= 0) {\n    // slide tackle\n    a.tackleT = 0.5;\n    a.tackleCd = 1.25;\n    const th = a.heading;\n    a.vel.set(Math.cos(th) * 11, 0, Math.sin(th) * 11);\n    audio.whoosh();\n    spawnParticles(a.pos, TURF, 8, { up: 2.5, vel: 3, life: 0.7 });\n    hud.setPower(0, false);\n  }\n}\n\n// ---------------------------------------------------------------- simulation\nfunction integrateAthletes(sdt) {\n  for (const a of players) {\n    a.kickT = Math.max(0, a.kickT - sdt);\n    a.tackleCd = Math.max(0, (a.tackleCd || 0) - sdt);\n    a.stunT = Math.max(0, (a.stunT || 0) - sdt);\n    a.touchCd = Math.max(0, (a.touchCd || 0) - sdt);\n\n    if (a.tackleT > 0) {\n      a.tackleT -= sdt;\n      a.vel.multiplyScalar(Math.max(0, 1 - sdt * 3.2));\n      // the slide can win the ball or dump the carrier\n      if (ballDist(a) < 1.15 && ball.pos.y < 1 && mayTouch(a)) {\n        const th = a.heading;\n        ball.kick(Math.cos(th), Math.sin(th), 8.5, 1.2);\n        game.lastTouch = a;\n        audio.kick(0.4);\n      }\n      const opp = TEAMS[1 - a.team.id];\n      for (const o of opp.athletes) {\n        if (o.stunT <= 0 && dist2D(a.pos.x, a.pos.z, o.pos.x, o.pos.z) < 1.0) {\n          o.stunT = 0.85;\n          o.stunDir = Math.random() < 0.5 ? -1 : 1;\n          o.vel.set(0, 0, 0);\n          audio.thud();\n          spawnParticles(o.pos, TURF, 6, { up: 2, vel: 3, life: 0.6 });\n        }\n      }\n    } else if (a.stunT > 0) {\n      a.vel.multiplyScalar(Math.max(0, 1 - sdt * 6));\n    } else {\n      // human control overrides tv (only while actually steering —\n      // hands-off, your player falls back to team AI, Sensible style)\n      if (a === game.controlled && game.humanActive && (game.state === 'play' || game.state === 'kickoff')) {\n        const iv = inputVec();\n        let sp = SPEED.human;\n        if (chargeT >= 0) sp *= 0.5;\n        if (hasBall(a)) sp *= 0.93;\n        a.tv.set(iv.x * sp, 0, iv.z * sp);\n      }\n      const k = 1 - Math.exp(-sdt * 8);\n      a.vel.x += (a.tv.x - a.vel.x) * k;\n      a.vel.z += (a.tv.z - a.vel.z) * k;\n    }\n\n    a.pos.x += a.vel.x * sdt;\n    a.pos.z += a.vel.z * sdt;\n    a.pos.x = clamp(a.pos.x, -PITCH.hw - 1.5, PITCH.hw + 1.5);\n    a.pos.z = clamp(a.pos.z, -PITCH.hl - 1.5, PITCH.hl + 1.5);\n    a.speed = Math.hypot(a.vel.x, a.vel.z);\n    if (a.speed > 0.7 && a.tackleT <= 0) a.heading = Math.atan2(a.vel.z, a.vel.x);\n  }\n\n  // gentle shoulder-to-shoulder separation\n  for (let i = 0; i < players.length; i++) {\n    for (let j = i + 1; j < players.length; j++) {\n      const A = players[i], B = players[j];\n      const dx = B.pos.x - A.pos.x, dz = B.pos.z - A.pos.z;\n      const d = Math.hypot(dx, dz);\n      if (d > 0.001 && d < 0.85) {\n        const push = (0.85 - d) * 0.5;\n        A.pos.x -= (dx / d) * push; A.pos.z -= (dz / d) * push;\n        B.pos.x += (dx / d) * push; B.pos.z += (dz / d) * push;\n      }\n    }\n  }\n}\n\nfunction ballTouches() {\n  if (ball.held) return;\n\n  // drop possession if the owner can no longer keep it\n  const o = ball.owner;\n  if (o && (o.stunT > 0 || o.tackleT > 0 || ballDist(o) > 2.1 || ball.pos.y > 1.35)) {\n    ball.owner = null;\n  }\n\n  for (const a of players) {\n    if (a === ball.owner) continue;\n    if (a.touchCd > 0 || a.stunT > 0 || a.kickT > 0.12 || a.tackleT > 0) continue;\n    if (!mayTouch(a)) continue;\n    if (ball.noStickT > 0 && game.lastTouch === a) continue; // your kick is away — let it run\n    const reach = a.diveT > 0 ? 1.55 : 0.95;\n    if (ballDist(a) > reach || ball.pos.y > (a.diveT > 0 ? 2.3 : 1.35)) continue;\n\n    if (a.role === 'gk') {\n      const gz = ownGoalZ(a.team);\n      const inBox = Math.abs(a.pos.z - gz) < PITCH.boxD && Math.abs(a.pos.x) < PITCH.boxW;\n      if (inBox) {\n        if (ball.groundSpeed() < 16) {\n          // safe hands: gather and hold\n          ball.held = a;\n          ball.owner = null;\n          a.holdT = 1.15;\n          a.diveT = 0;\n          game.lastTouch = a;\n          if (game.restrictT > 0 && a.team.id === game.restrictTeam) game.restrictT = 0;\n          a.touchCd = 0.5;\n        } else {\n          // too hot to hold — parry it away (sometimes straight into danger)\n          const away = Math.sign(gz) * -1;\n          const weak = Math.random() < 0.3;\n          ball.owner = null;\n          ball.noStickT = 0.25;\n          ball.vel.set(\n            (Math.sign(ball.pos.x - a.pos.x) || (Math.random() < 0.5 ? -1 : 1)) * (3 + Math.random() * 6),\n            2 + Math.random() * 1.5,\n            away * (weak ? 2.5 : 5 + Math.random() * 5)\n          );\n          game.lastTouch = a;\n          hud.showPopup('WHAT A SAVE!', a.team.id !== 0);\n          audio.cheer();\n          game.exciteBoost = Math.max(game.exciteBoost || 0, 0.7);\n          game.shake = Math.max(game.shake, 0.2);\n          a.touchCd = 0.45;\n        }\n        continue;\n      }\n    }\n\n    // a rocket can't be trapped — it deflects off the body and runs loose\n    if (ball.groundSpeed() > 14) {\n      ball.vel.x = ball.vel.x * 0.35 + (Math.random() - 0.5) * 4;\n      ball.vel.z = ball.vel.z * 0.35 + (Math.random() - 0.5) * 4;\n      if (ball.vel.y > 1.5) ball.vel.y *= 0.4;\n      game.lastTouch = a;\n      if (game.restrictT > 0 && a.team.id === game.restrictTeam) game.restrictT = 0;\n      a.touchCd = 0.35;\n      continue;\n    }\n\n    // win the ball — it now sticks to this player's boots\n    const stolenFrom = ball.owner;\n    ball.owner = a;\n    if (ball.groundSpeed() > 7) {\n      // cushioned first touch kills the pace\n      ball.vel.x *= 0.3;\n      ball.vel.z *= 0.3;\n      if (ball.vel.y > 1.5) ball.vel.y *= 0.35;\n    }\n    game.lastTouch = a;\n    if (game.restrictT > 0 && a.team.id === game.restrictTeam) game.restrictT = 0;\n    a.touchCd = 0.3;\n    if (stolenFrom && stolenFrom.team !== a.team) {\n      stolenFrom.touchCd = 0.6; // no instant win-back — makes tackles feel real\n      if (a === game.controlled || stolenFrom === game.controlled) audio.bounce();\n    }\n  }\n}\n\nfunction checkBallOut() {\n  const { hw, hl, goalHW, goalH } = PITCH;\n  const bx = ball.pos.x, bz = ball.pos.z;\n\n  // goal?\n  if (Math.abs(bz) > hl + 0.3 && Math.abs(bx) < goalHW && ball.pos.y < goalH) {\n    const scoringId = game.attackDir[0] === Math.sign(bz) ? 0 : 1;\n    onGoal(scoringId);\n    return;\n  }\n\n  // throw-in\n  if (Math.abs(bx) > hw + 0.5) {\n    const toTeam = game.lastTouch ? 1 - game.lastTouch.team.id : 0;\n    ball.reset(Math.sign(bx) * (hw - 0.4), clamp(bz, -hl + 2, hl - 2));\n    game.restrictTeam = toTeam;\n    game.restrictT = 2.6;\n    hud.showPopup('THROW IN', false);\n    audio.ref(1);\n    return;\n  }\n\n  // over the goal line, wide of the frame\n  if (Math.abs(bz) > hl + 1.9 || (Math.abs(bz) > hl + 0.5 && (Math.abs(bx) >= goalHW || ball.pos.y >= goalH))) {\n    const defId = game.attackDir[0] === Math.sign(bz) ? 1 : 0; // team defending this end\n    const lastId = game.lastTouch ? game.lastTouch.team.id : defId;\n    if (lastId === defId) {\n      // corner to the attackers\n      const atkId = 1 - defId;\n      ball.reset(Math.sign(bx || 1) * (hw - 0.6), Math.sign(bz) * (hl - 0.6));\n      game.restrictTeam = atkId;\n      game.restrictT = 2.6;\n      hud.showPopup('CORNER!', atkId !== 0);\n      audio.ref(1);\n      game.exciteBoost = Math.max(game.exciteBoost || 0, 0.45);\n    } else {\n      // goal kick\n      ball.reset(0, Math.sign(bz) * (hl - 4.5));\n      game.restrictTeam = defId;\n      game.restrictT = 2.6;\n      hud.showPopup('GOAL KICK', false);\n      audio.ref(1);\n    }\n  }\n}\n\n// ---------------------------------------------------------------- camera\nfunction camDesired() {\n  if (cam.mode === 'orbit') {\n    return {\n      pos: new THREE.Vector3(Math.cos(cam.orbitAngle) * 120, 78, Math.sin(cam.orbitAngle) * 100),\n      look: new THREE.Vector3(0, 2, 0),\n    };\n  }\n  if (cam.mode === 'goalcam' && game.scorer) {\n    const a = game.time * 0.55;\n    const s = game.scorer.pos;\n    return {\n      pos: new THREE.Vector3(s.x + Math.cos(a) * 8.5, 4.2, s.z + Math.sin(a) * 8.5),\n      look: new THREE.Vector3(s.x, 1.4, s.z),\n    };\n  }\n  // follow: Sensible-style raised chase cam, play runs up the screen\n  const bx = clamp(ball.pos.x, -14, 14);\n  const bz = clamp(ball.pos.z, -PITCH.hl + 2, PITCH.hl - 2);\n  return {\n    pos: new THREE.Vector3(bx * 0.75, 50, bz * 0.86 + 30),\n    look: new THREE.Vector3(bx * 0.92, 0.5, bz * 0.94 + 1),\n  };\n}\n\n// ---------------------------------------------------------------- main loop\nlet last = performance.now();\ngame.fovKick = 0;\ngame.exciteBoost = 0;\nlet stepPhase = 0;\n\nsetupTitleScene();\nhud.hud.root.classList.add('hidden');\n\n// live handle for quick inspection from the console\nwindow.__svf = { game, ball, players, TEAMS };\n\nfunction frame(now) {\n  requestAnimationFrame(frame);\n  const dt = Math.min(0.05, (now - last) / 1000);\n  last = now;\n  game.time += dt;\n\n  // timescale (slow-mo after goals)\n  if (game.tsTimer > 0) {\n    game.tsTimer -= dt;\n    if (game.tsTimer <= 0) game.tsTarget = 1;\n  }\n  game.timeScale += (game.tsTarget - game.timeScale) * Math.min(1, dt * 6);\n  const sdt = dt * game.timeScale;\n\n  if (enterQueued) { enterQueued = false; handleEnter(); }\n\n  // is the human actually steering? (with a short grace period so the AI\n  // doesn't snatch the ball between your direction changes)\n  const ivNow = inputVec();\n  if (Math.hypot(ivNow.x, ivNow.z) > 0.08 || chargeT >= 0) game.lastInputT = game.time;\n  game.humanActive = game.lastInputT != null && game.time - game.lastInputT < 0.6;\n\n  // charge meter\n  if (chargeT >= 0) {\n    chargeT += dt;\n    const p = clamp(chargeT / 0.9, 0, 1);\n    if (chargeT > 0.12) hud.setPower(p, true, p > 0.26 ? 'SHOOT POWER' : 'RELEASE TO PASS');\n  }\n\n  // state timers\n  game.restrictT = Math.max(0, game.restrictT - sdt);\n  if (game.state === 'kickoff') {\n    game.stateT -= dt;\n    if (game.stateT <= 0) { game.state = 'play'; }\n  } else if (game.state === 'goal') {\n    game.stateT -= dt;\n    if (game.stateT <= 0) {\n      setupKickoff(game.concededId);\n      cam.mode = 'follow';\n      audio.ref(1);\n      hud.showMsg('KICK OFF', 'go');\n    }\n  } else if (game.state === 'play') {\n    game.clock += dt;\n    if (game.clock >= game.HALF_LEN) endHalf();\n  }\n\n  const inMatch = game.state === 'play' || game.state === 'kickoff';\n\n  // simulation\n  if (inMatch || game.state === 'goal') {\n    if (inMatch) {\n      pickControlled();\n      updateAI(sdt);\n    }\n    integrateAthletes(sdt);\n    ball.update(sdt);\n    if (game.state === 'play') {\n      ballTouches();\n      checkBallOut();\n    }\n  } else {\n    ball.update(sdt);\n  }\n\n  // post / crossbar clang\n  if (ball.hitPost) {\n    ball.hitPost = false;\n    audio.post();\n    hud.showPopup('OFF THE WOODWORK!', false);\n    game.exciteBoost = Math.max(game.exciteBoost || 0, 0.6);\n  }\n\n  // excitement: proximity to either box + event spikes\n  game.exciteBoost = Math.max(0, (game.exciteBoost || 0) - dt * 0.25);\n  const nearGoal = Math.min(\n    dist2D(ball.pos.x, ball.pos.z, 0, -PITCH.hl),\n    dist2D(ball.pos.x, ball.pos.z, 0, PITCH.hl)\n  );\n  const ex = clamp(0.22 + (1 - clamp(nearGoal / 26, 0, 1)) * 0.4 + game.exciteBoost, 0, 1);\n  game.excitement = ex;\n  audio.setAmbience(ex);\n  if (game.state !== 'title') audio.ambient(dt, ex);\n\n  // footsteps for your player\n  const ctl = game.controlled;\n  if (ctl && inMatch && ctl.speed > 3) {\n    stepPhase += sdt * (4 + ctl.speed * 1.9);\n    if (stepPhase > Math.PI) { stepPhase -= Math.PI; audio.step(); }\n  }\n\n  // ---------------- athlete visuals\n  for (const a of players) {\n    let mode = 'idle';\n    const opt = {};\n    if (game.state === 'goal') {\n      const scored = game.scorer && a.team === game.scorer.team;\n      mode = a.role === 'gk' && !scored ? 'dejected' : scored ? 'celebrate' : 'dejected';\n    } else if (a.stunT > 0) {\n      mode = 'dive'; opt.dir = a.stunDir || 1;\n    } else if (a.tackleT > 0) {\n      mode = 'tackle';\n    } else if (a.kickT > 0) {\n      mode = 'kick';\n    } else if (a.diveT > 0) {\n      a.diveT -= sdt;\n      mode = 'dive'; opt.dir = a.diveDir || 1;\n    } else if (a === ctl && chargeT > 0.26 && a.speed < 2) {\n      mode = 'charge';\n    } else if (a.speed > 0.9) {\n      mode = 'run'; opt.speed = a.speed;\n    } else if (a.role === 'gk' && inMatch && ballDist(a) < 20) {\n      mode = 'crouch';\n    }\n    a.update(sdt, mode, opt);\n    a.group.position.set(a.pos.x, 0, a.pos.z);\n    const dx = Math.cos(a.heading), dz = Math.sin(a.heading);\n    a.group.rotation.y = headingFor(dx, dz);\n  }\n\n  // marker over your player\n  if (ctl && inMatch) {\n    marker.visible = true;\n    marker.position.set(ctl.pos.x, 3.3 + Math.sin(game.time * 3) * 0.12, ctl.pos.z);\n  } else marker.visible = false;\n\n  // ---------------- HUD\n  if (game.state !== 'title') hud.setClock(fmtClock());\n\n  // ---------------- camera\n  if (cam.mode === 'orbit') cam.orbitAngle += dt * 0.05;\n  const want = camDesired();\n  const ck = 1 - Math.exp(-dt * (cam.mode === 'goalcam' ? 3 : 4.5));\n  cam.pos.lerp(want.pos, ck);\n  cam.look.lerp(want.look, ck);\n  game.shake = Math.max(0, game.shake - dt * 1.6);\n  const sh = game.shake * game.shake;\n  camera.position.set(\n    cam.pos.x + (Math.random() - 0.5) * sh * 1.6,\n    cam.pos.y + (Math.random() - 0.5) * sh * 1.2,\n    cam.pos.z + (Math.random() - 0.5) * sh * 1.6\n  );\n  camera.lookAt(cam.look);\n  game.fovKick = Math.max(0, (game.fovKick || 0) - dt * 9);\n  camera.fov = 30 + game.fovKick;\n  camera.updateProjectionMatrix();\n\n  // world + particles\n  stadium.tick(game.time, dt, ex);\n  updateParticles(sdt);\n\n  renderer.render(scene, camera);\n}\nrequestAnimationFrame(frame);\n"},{"name":"world.js","content":"// world.js — the voxel football ground. 1 unit = 1 metre.\n// A tight British-style box: four rectangular stands with open corners,\n// floodlight pylons in the gaps, mown-stripe pitch, proper markings.\n// Play runs along Z — goals at z = ±29.\nimport { THREE, BOX, boxMesh, buildTiles } from './lib.js';\n\nexport const PITCH = {\n  hw: 19,      // half width  (x)\n  hl: 29,      // half length (z)\n  goalHW: 3.8, // goal mouth half width\n  goalH: 2.5,  // crossbar height\n  boxW: 11,    // penalty area half width\n  boxD: 9,     // penalty area depth\n  sixW: 6,     // six-yard half width\n  sixD: 3.5,\n  spot: 7.5,   // penalty spot distance\n};\nexport const CENTER = new THREE.Vector3(0, 0, 0);\n\n// heading (group.rotation.y) for a direction — athlete model faces +X at PI/2\nexport function headingFor(dx, dz) { return Math.PI / 2 - Math.atan2(dz, dx); }\n\nexport function buildStadium(scene) {\n  const skyCol = new THREE.Color('#a5ddff');\n  scene.background = skyCol;\n  scene.fog = new THREE.Fog(skyCol, 300, 560);\n\n  const hemi = new THREE.HemisphereLight(0xcfe8ff, 0x5e7a4a, 0.95);\n  scene.add(hemi);\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  sun.shadow.camera.left = -95;\n  sun.shadow.camera.right = 95;\n  sun.shadow.camera.top = 95;\n  sun.shadow.camera.bottom = -95;\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(380, 1, 380, '#4a8438');\n  ground.position.y = -0.75;\n  ground.castShadow = false;\n  scene.add(ground);\n\n  const { hw, hl, goalHW, goalH, boxW, boxD, sixW, sixD, spot } = PITCH;\n\n  // -------- the pitch: mown stripes across the length, subtle flecks\n  const grass = [];\n  for (let x = -hw - 3; x <= hw + 3; x += 2) {\n    for (let z = -hl - 4; z <= hl + 4; z += 2) {\n      const stripe = Math.floor((z + hl) / 5.8) % 2 === 0;\n      let col = stripe ? '#5fae3f' : '#54a137';\n      if (Math.random() < 0.035) col = '#4b9231';\n      grass.push({ x, y: -0.5, z, sx: 2, sy: 1, sz: 2, color: col });\n    }\n  }\n  scene.add(buildTiles(grass));\n\n  // -------- markings, all little white voxels sitting on the turf\n  const M = [];\n  const W = '#f3efe2';\n  const line = (x1, z1, x2, z2) => {\n    const dx = x2 - x1, dz = z2 - z1;\n    const len = Math.hypot(dx, dz);\n    M.push({\n      x: (x1 + x2) / 2, y: 0.06, z: (z1 + z2) / 2,\n      sx: Math.abs(dx) > Math.abs(dz) ? len : 0.14,\n      sy: 0.05,\n      sz: Math.abs(dz) >= Math.abs(dx) ? len : 0.14,\n      color: W,\n    });\n  };\n  // touchlines + goal lines\n  line(-hw, -hl, hw, -hl); line(-hw, hl, hw, hl);\n  line(-hw, -hl, -hw, hl); line(hw, -hl, hw, hl);\n  // halfway + centre circle + spot\n  line(-hw, 0, hw, 0);\n  for (let a = 0; a < Math.PI * 2; a += 0.09) {\n    M.push({ x: Math.cos(a) * 6, y: 0.06, z: Math.sin(a) * 6, sx: 0.32, sy: 0.05, sz: 0.32, color: W });\n  }\n  M.push({ x: 0, y: 0.07, z: 0, sx: 0.55, sy: 0.05, sz: 0.55, color: W });\n  for (const e of [-1, 1]) {\n    const gl = hl * e;\n    // penalty area\n    line(-boxW, gl, -boxW, gl - boxD * e);\n    line(boxW, gl, boxW, gl - boxD * e);\n    line(-boxW, gl - boxD * e, boxW, gl - boxD * e);\n    // six-yard box\n    line(-sixW, gl, -sixW, gl - sixD * e);\n    line(sixW, gl, sixW, gl - sixD * e);\n    line(-sixW, gl - sixD * e, sixW, gl - sixD * e);\n    // spot + arc outside the area\n    M.push({ x: 0, y: 0.07, z: gl - spot * e, sx: 0.5, sy: 0.05, sz: 0.5, color: W });\n    for (let a = 0; a < Math.PI * 2; a += 0.07) {\n      const px = Math.cos(a) * 6, pz = (gl - spot * e) + Math.sin(a) * 6;\n      if (e > 0 ? pz < gl - boxD : pz > gl + boxD) continue; // hmm — keep only outside box\n      if (Math.abs(pz - gl) > boxD || Math.abs(px) > boxW) {\n        if (e > 0 ? pz <= gl - boxD : pz >= gl + boxD) {\n          M.push({ x: px, y: 0.06, z: pz, sx: 0.3, sy: 0.05, sz: 0.3, color: W });\n        }\n      }\n    }\n    // corner arcs\n    for (const s of [-1, 1]) {\n      for (let a = 0; a < Math.PI / 2; a += 0.22) {\n        M.push({\n          x: s * hw - s * Math.cos(a) * 1.2, y: 0.06,\n          z: gl - e * Math.sin(a) * 1.2,\n          sx: 0.22, sy: 0.05, sz: 0.22, color: W,\n        });\n      }\n      // corner flag\n      const g = new THREE.Group();\n      const pole = boxMesh(0.09, 1.7, 0.09, '#f5f1e6');\n      pole.position.y = 0.85;\n      const flag = boxMesh(0.55, 0.4, 0.06, '#ff4b3a');\n      flag.position.set(0.3, 1.45, 0);\n      g.add(pole, flag);\n      g.position.set(s * hw, 0, gl);\n      scene.add(g);\n    }\n  }\n  scene.add(buildTiles(M));\n\n  // -------- goals: white frame + translucent voxel netting\n  const netTiles = [];\n  for (const e of [-1, 1]) {\n    const gl = hl * e;\n    const frame = [];\n    const postC = '#f8f8f8';\n    for (const s of [-1, 1]) {\n      frame.push({ x: s * goalHW, y: goalH / 2, z: gl, sx: 0.22, sy: goalH, sz: 0.22, color: postC });\n      // side net frame slanting back\n      frame.push({ x: s * goalHW, y: 0.55, z: gl + e * 0.9, sx: 0.14, sy: 1.1, sz: 0.14, color: postC });\n    }\n    frame.push({ x: 0, y: goalH, z: gl, sx: goalHW * 2 + 0.22, sy: 0.22, sz: 0.22, color: postC });\n    scene.add(buildTiles(frame, { shadowCast: true }));\n\n    // net: back panel + roof + sides, thin translucent bars\n    const bz = gl + e * 1.7;\n    for (let x = -goalHW; x <= goalHW + 0.01; x += 0.55) {\n      netTiles.push({ x, y: 0.85, z: bz, sx: 0.05, sy: 1.7, sz: 0.05, color: '#ffffff' });\n    }\n    for (let y = 0.15; y <= 1.7; y += 0.42) {\n      netTiles.push({ x: 0, y, z: bz, sx: goalHW * 2, sy: 0.05, sz: 0.05, color: '#ffffff' });\n    }\n    // sloping roof net from crossbar to back frame\n    for (let x = -goalHW; x <= goalHW + 0.01; x += 0.55) {\n      netTiles.push({ x, y: (goalH + 1.7) / 2, z: gl + e * 0.85, sx: 0.05, sy: 0.06, sz: 1.95, ry: 0, color: '#ffffff' });\n    }\n    // side panels\n    for (const s of [-1, 1]) {\n      for (let y = 0.2; y <= goalH - 0.2; y += 0.5) {\n        netTiles.push({ x: s * goalHW, y, z: gl + e * 0.85, sx: 0.05, sy: 0.05, sz: 1.7, color: '#ffffff' });\n      }\n    }\n  }\n  scene.add(buildTiles(netTiles, { matOpts: { transparent: true, opacity: 0.42, roughness: 1 } }));\n\n  // -------- ad boards ringing the pitch\n  const ads = [];\n  const adCols = ['#1f9e8e', '#f5f1e6', '#ff4b3a', '#2b6cb0', '#f5f1e6', '#ffb020'];\n  let adI = 0;\n  for (let z = -hl - 1; z <= hl + 1; z += 5.2) {\n    for (const s of [-1, 1]) {\n      ads.push({ x: s * (hw + 3.2), y: 0.55, z, sx: 0.35, sy: 1.1, sz: 4.6, color: adCols[adI++ % adCols.length] });\n    }\n  }\n  for (let x = -hw - 1; x <= hw + 1; x += 5.2) {\n    for (const s of [-1, 1]) {\n      if (s > 0 && Math.abs(x) < 6) continue; // gap behind the south goal for the tunnel\n      ads.push({ x, y: 0.55, z: s * (hl + 3.6), sx: 4.6, sy: 1.1, sz: 0.35, color: adCols[adI++ % adCols.length] });\n    }\n  }\n  scene.add(buildTiles(ads, { shadowCast: true }));\n\n  // -------- four rectangular stands with open corners + 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;\n  const SIDE_OFF = hw + 8;   // east/west stands\n  const END_OFF = hl + 7.5;  // north/south stands\n  const rimY = 0.9 + (TIERS - 1) * STEP_UP;\n\n  // seat + crowd row helper: position p along the wall, wall normal (nx,nz)\n  function standRow(t, px, pz, ry, dirX, dirZ, secCols, sec) {\n    const y = 0.9 + t * STEP_UP;\n    tiers.push({ x: px, y, z: pz, sx: 3.0, sy: 1.15, sz: 1.9, ry, color: t % 2 === 0 ? '#b9bfc9' : '#aab0bb' });\n    for (const s of [-1.05, 0, 1.05]) {\n      if (Math.random() < 0.92) {\n        crowdList.push({\n          x: px + dirX * s + (Math.random() - 0.5) * 0.25,\n          y: y + 0.95,\n          z: pz + 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  const secCols = ['#ff4b3a', '#f5f1e6', '#274690', '#ffb020', '#1f9e8e', '#e6e6e6'];\n  for (let t = 0; t < TIERS; t++) {\n    // east & west (long sides): rows run along z\n    const offX = SIDE_OFF + t * STEP_OUT;\n    for (let z = -(hl + 1.5); z <= hl + 1.5; z += 3.2) {\n      standRow(t, offX, z, Math.PI / 2, 0, -1, secCols, Math.floor((z + hl) / 12));\n      standRow(t, -offX, z, Math.PI / 2, 0, -1, secCols, Math.floor((z + hl) / 12) + 3);\n    }\n    // north & south (behind the goals): rows run along x\n    const offZ = END_OFF + t * STEP_OUT;\n    for (let x = -(hw + 1); x <= hw + 1; x += 3.2) {\n      standRow(t, x, -offZ, 0, 1, 0, secCols, Math.floor((x + hw) / 10) + 1);\n      standRow(t, x, offZ, 0, 1, 0, secCols, Math.floor((x + hw) / 10) + 4);\n    }\n  }\n  // back walls\n  for (let z = -(hl + 1.5); z <= hl + 1.5; z += 3.2) {\n    for (const s of [-1, 1]) {\n      tiers.push({ x: s * (SIDE_OFF + (TIERS - 1) * STEP_OUT + 1.6), y: rimY + 1.3, z, sx: 0.6, sy: 1.6, sz: 3.2, color: '#9aa1ad' });\n    }\n  }\n  for (let x = -(hw + 1); x <= hw + 1; x += 3.2) {\n    for (const s of [-1, 1]) {\n      tiers.push({ x, y: rimY + 1.3, z: s * (END_OFF + (TIERS - 1) * STEP_OUT + 1.6), sx: 3.2, sy: 1.6, sz: 0.6, color: '#9aa1ad' });\n    }\n  }\n  scene.add(buildTiles(tiers, { shadowCast: true }));\n\n  // floating roof canopies, high so the follow camera sees under the lip\n  const roofTiles = [];\n  const roofY = rimY + 8.2;\n  const roofDepth = TIERS * STEP_OUT + 1;\n  for (let z = -(hl + 1.5); z <= hl + 1.5; z += 3.3) {\n    for (const s of [-1, 1]) {\n      roofTiles.push({ x: s * (SIDE_OFF + (TIERS - 1) * STEP_OUT / 2 + 2.5), y: roofY, z, sx: roofDepth, sy: 0.35, sz: 3.3, color: '#e8ebf0' });\n    }\n  }\n  for (let x = -(hw + 1); x <= hw + 1; x += 3.3) {\n    for (const s of [-1, 1]) {\n      roofTiles.push({ x, y: roofY, z: s * (END_OFF + (TIERS - 1) * STEP_OUT / 2 + 2.5), sx: 3.3, sy: 0.35, sz: roofDepth, color: '#e8ebf0' });\n    }\n  }\n  // roof support columns\n  for (const s of [-1, 1]) {\n    for (const z of [-hl, 0, hl]) {\n      roofTiles.push({ x: s * (SIDE_OFF + (TIERS - 1) * STEP_OUT + 2.4), y: roofY / 2, z, sx: 0.8, sy: roofY, sz: 0.8, color: '#9aa1ad' });\n    }\n    for (const x of [-hw, 0, hw]) {\n      roofTiles.push({ x, y: roofY / 2, z: s * (END_OFF + (TIERS - 1) * STEP_OUT + 2.4), sx: 0.8, sy: roofY, sz: 0.8, color: '#9aa1ad' });\n    }\n  }\n  scene.add(buildTiles(roofTiles, { shadowCast: true }));\n\n  // -------- little spectator sprites (4 instanced layers, animated in tick)\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    m.frustumCulled = false; // matrices move every tick — never let it cull\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 stand rims\n  const flags = [];\n  const flagCols = ['#ff4b3a', '#ffb020', '#4aa3ff', '#7ce34a', '#f5f1e6', '#c084fc'];\n  let fi = 0;\n  const flagSpots = [];\n  for (let z = -hl; z <= hl; z += 13) {\n    flagSpots.push([SIDE_OFF + (TIERS - 1) * STEP_OUT + 1.6, z]);\n    flagSpots.push([-(SIDE_OFF + (TIERS - 1) * STEP_OUT + 1.6), z]);\n  }\n  for (let x = -hw; x <= hw; x += 13) {\n    flagSpots.push([x, END_OFF + (TIERS - 1) * STEP_OUT + 1.6]);\n    flagSpots.push([x, -(END_OFF + (TIERS - 1) * STEP_OUT + 1.6)]);\n  }\n  for (const [x, z] of flagSpots) {\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 + 2.1, z);\n    g.userData.flag = flag;\n    scene.add(g);\n    flags.push(g);\n    fi++;\n  }\n\n  // -------- floodlight pylons in the four open corners\n  for (const [tx, tz] of [[-(hw + 16), -(hl + 13)], [-(hw + 16), hl + 13], [hw + 16, -(hl + 13)], [hw + 16, hl + 13]]) {\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  // -------- trees around the outside\n  for (let i = 0; i < 26; i++) {\n    const a = (i / 26) * Math.PI * 2;\n    const x = Math.cos(a) * (hw + 42) + (Math.random() - 0.5) * 8;\n    const z = Math.sin(a) * (hl + 36) + (Math.random() - 0.5) * 8;\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, 0, z);\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,\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  // -------- big screen above the north stand (the end you attack first)\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 PARK', '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 + 11, -(END_OFF + (TIERS - 1) * STEP_OUT + 4));\n  scene.add(sbGroup);\n\n  // ---------------- per-frame tick\n  const dummy = new THREE.Object3D();\n  let tickFrame = 0;\n  function tick(time, dt, excitement) {\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 — chibi proportions matching the players\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        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        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        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        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\n  return { tick, scoreboard: drawScoreboard, hemi, sun };\n}\n"}],"folders":[]},{"folder":"other","files":[{"name":"main.sketch","content":""}],"folders":[],"collapsed":true}]},"variants":null,"createdAt":"2026-07-15T13:55:49.000Z","updatedAt":"2026-07-15T15:38:55.876Z","hasThumbnail":true}}