← Gorillaz results

Gorillaz

Qwen 3.8 27B GGUF · full

Initial view of Gorillaz

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 52,575 bytes · SHA-256 983f439225f3
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gorillaz — Banana Bomb Artillery</title>
<style>
:root{
  --bg:#07070f; --panel:#141428; --panel2:#1b1b36; --line:#34346b;
  --gold:#ffd23f; --cream:#f2ead8; --dim:#8d8db3;
  --p1:#ff5d5d; --p2:#5da2ff; --green:#7dff8a; --amber:#ffb03a;
}
*{box-sizing:border-box; margin:0; padding:0;}
html,body{height:100%;}
body{
  background:var(--bg);
  background-image:
    repeating-linear-gradient(0deg, rgba(255,255,255,.022) 0 1px, transparent 1px 3px),
    radial-gradient(ellipse at 50% -10%, #17173a 0%, #07070f 60%);
  font-family:"Courier New", ui-monospace, Menlo, monospace;
  color:var(--cream);
  display:flex; flex-direction:column; align-items:center;
  overflow:hidden; user-select:none;
}
#app{
  position:relative; width:min(96vw, calc(92vh * (1280/720)));
  aspect-ratio:1280/720;
  border:3px solid var(--line);
  border-radius:10px;
  box-shadow:0 0 0 3px #05050c, 0 0 42px rgba(90,90,255,.16), inset 0 0 24px rgba(0,0,0,.6);
  background:#0b0b1c; overflow:hidden;
  display:flex; flex-direction:column;
}
/* ---------- HUD ---------- */
#hud{
  height:46px; flex:0 0 46px;
  display:flex; align-items:center; gap:8px; padding:0 10px;
  background:linear-gradient(#1d1d3f, #12122a);
  border-bottom:2px solid var(--line);
  font-size:13px; letter-spacing:1px;
}
.brand{font-size:16px; font-weight:bold; color:var(--gold); text-shadow:0 0 8px rgba(255,210,63,.6); white-space:nowrap;}
.brand .bz{color:var(--green); text-shadow:0 0 8px rgba(125,255,138,.7);}
.hud-cell{
  display:flex; align-items:center; gap:6px;
  background:rgba(0,0,0,.35); border:1px solid var(--line); border-radius:6px;
  padding:4px 9px; white-space:nowrap;
}
.hud-cell b{color:var(--gold); font-size:14px;}
#turnChip{font-weight:bold; padding:4px 12px; border-radius:6px; border:1px solid transparent;}
#turnChip.t1{background:rgba(255,93,93,.16); color:var(--p1); border-color:var(--p1); box-shadow:0 0 10px rgba(255,93,93,.35);}
#turnChip.t2{background:rgba(93,162,255,.16); color:var(--p2); border-color:var(--p2); box-shadow:0 0 10px rgba(93,162,255,.35);}
.score b{font-size:15px;}
.s1{color:var(--p1);} .s2{color:var(--p2);}
.spacer{flex:1;}
#windArrow{font-size:15px; color:var(--green); width:16px; text-align:center;}
#windVal{min-width:34px; text-align:center;}
#aimReadout{color:var(--cream); font-size:13px;}
#aimReadout b{color:var(--green);}
button{
  font-family:inherit; font-size:12px; letter-spacing:1px; cursor:pointer;
  background:#20204a; color:var(--cream); border:1px solid var(--line);
  border-radius:6px; padding:5px 10px; transition:all .12s;
}
button:hover{background:#2c2c62; border-color:var(--gold); color:var(--gold); transform:translateY(-1px);}
button:active{transform:translateY(1px);}
button:disabled{opacity:.4; cursor:default; transform:none;}
/* ---------- STAGE ---------- */
#stage{position:relative; flex:1; min-height:0;}
canvas{position:absolute; inset:0; width:100%; height:100%; image-rendering:pixelated; display:block;}
#fxCanvas{z-index:2;}
/* ---------- OVERLAYS ---------- */
.overlay{
  position:absolute; inset:0; z-index:10;
  background:rgba(6,6,16,.78);
  display:flex; align-items:center; justify-content:center;
  backdrop-filter:blur(2px);
}
.hidden{display:none !important;}
.card{
  background:linear-gradient(#191938, #101024);
  border:2px solid var(--gold); border-radius:12px;
  box-shadow:0 0 34px rgba(255,210,63,.22), inset 0 0 20px rgba(0,0,0,.5);
  padding:26px 34px; text-align:center; max-width:78%;
}
.card h1{
  font-size:52px; letter-spacing:6px; color:var(--gold);
  text-shadow:3px 3px 0 #7a3b00, 0 0 22px rgba(255,210,63,.55);
  margin-bottom:4px;
}
.card h1 .bz{color:var(--green); text-shadow:3px 3px 0 #1c5c26, 0 0 22px rgba(125,255,138,.6);}
.tagline{color:var(--dim); font-size:13px; letter-spacing:2px; margin-bottom:16px;}
.card p{font-size:13px; line-height:1.7; color:var(--cream); margin-bottom:14px;}
.card .keys{font-size:12px; color:var(--dim); text-align:left; display:inline-block; margin-bottom:16px; line-height:2;}
.kbd{
  display:inline-block; background:#26265a; border:1px solid var(--line); border-bottom-width:3px;
  border-radius:5px; padding:1px 8px; color:var(--gold); font-size:11px; margin:0 2px;
}
.btnrow{display:flex; gap:10px; justify-content:center; flex-wrap:wrap;}
.big{font-size:15px; padding:9px 22px; letter-spacing:2px;}
.gold{background:linear-gradient(#ffd23f,#f0a92d); color:#3a2400; border-color:#8a5c00; font-weight:bold;}
.gold:hover{color:#1c1100; box-shadow:0 0 16px rgba(255,210,63,.5);}
#winBanner{position:absolute; top:14%; left:0; right:0; z-index:8; text-align:center; pointer-events:none;}
#winBanner .wname{
  font-size:46px; font-weight:bold; letter-spacing:4px;
  animation:pop .45s cubic-bezier(.2,1.6,.4,1) both;
}
#winBanner .wsub{font-size:14px; letter-spacing:3px; color:var(--cream); margin-top:6px; text-shadow:0 0 8px #000;}
@keyframes pop{0%{transform:scale(.2); opacity:0;} 100%{transform:scale(1); opacity:1;}}
#toast{
  position:absolute; left:50%; bottom:14px; transform:translateX(-50%);
  z-index:9; background:rgba(10,10,24,.9); border:1px solid var(--gold); color:var(--gold);
  border-radius:6px; padding:6px 16px; font-size:12px; letter-spacing:1px;
  transition:opacity .4s; pointer-events:none;
}
/* ---------- TUNING PANEL ---------- */
#tunePanel{
  position:absolute; top:0; right:0; bottom:0; width:352px; z-index:20;
  background:linear-gradient(#14142e, #0d0d1e);
  border-left:2px solid var(--gold);
  box-shadow:-12px 0 30px rgba(0,0,0,.55);
  display:flex; flex-direction:column;
  transform:translateX(105%); transition:transform .22s cubic-bezier(.2,.9,.3,1);
}
#tunePanel.open{transform:translateX(0);}
.tune-head{padding:12px 14px 8px; border-bottom:1px solid var(--line);}
.tune-head h2{font-size:15px; letter-spacing:2px; color:var(--gold); margin-bottom:8px;}
#liveBadge{
  display:inline-flex; align-items:center; gap:7px;
  font-size:10px; letter-spacing:2px; color:var(--green);
  border:1px solid var(--green); border-radius:20px; padding:3px 10px;
  background:rgba(125,255,138,.08);
}
#liveBadge .dot{width:7px; height:7px; border-radius:50%; background:var(--green); box-shadow:0 0 8px var(--green); animation:blink 1.4s infinite;}
@keyframes blink{0%,100%{opacity:1;} 50%{opacity:.35;}}
.tune-body{flex:1; overflow-y:auto; padding:6px 14px 14px;}
.tune-body::-webkit-scrollbar{width:8px;}
.tune-body::-webkit-scrollbar-thumb{background:var(--line); border-radius:4px;}
.tgroup{margin-top:12px;}
.tgroup h3{font-size:11px; letter-spacing:2px; color:var(--dim); border-bottom:1px dashed #2c2c58; padding-bottom:4px; margin-bottom:6px;}
.trow{display:flex; align-items:center; gap:6px; padding:5px 0; font-size:12px;}
.trow .tname{flex:1; color:var(--cream); letter-spacing:.5px;}
.trow input[type=range]{width:96px; accent-color:var(--gold); height:14px;}
.trow input[type=number]{
  width:64px; background:#0c0c1e; border:1px solid var(--line); border-radius:5px;
  color:var(--cream); font-family:inherit; font-size:12px; padding:3px 5px; text-align:right;
}
.trow input.modified{border-color:var(--green); color:var(--green); box-shadow:0 0 7px rgba(125,255,138,.35);}
.trow .chk{accent-color:var(--gold); width:15px; height:15px;}
.tnote{font-size:10px; color:var(--dim); margin-top:2px; letter-spacing:.5px;}
.tune-foot{padding:10px 14px; border-top:1px solid var(--line); display:flex; gap:8px;}
.tune-foot button{flex:1;}
#tuneClose{position:absolute; top:8px; right:10px; padding:2px 9px; z-index:2;}
/* ---------- ABOUT ---------- */
#aboutCard{max-width:640px; text-align:left;}
#aboutCard h2{color:var(--gold); letter-spacing:3px; margin-bottom:12px; text-align:center;}
#aboutCard p{font-size:12px; line-height:1.8; margin-bottom:10px;}
#aboutCard .keys{text-align:left;}
</style>
</head>
<body>
<div id="app">
  <div id="hud">
    <span class="brand">GORILLA<span class="bz">Z</span></span>
    <span id="turnChip" class="t1">PLAYER 1 TURN</span>
    <span class="hud-cell" id="windCell" title="Current wind — horizontal force on the banana">
      <span id="windArrow">→</span><b id="windVal">0</b><span>WIND</span>
    </span>
    <span class="hud-cell" id="aimReadout">ANGLE <b id="angleVal">45</b>°&nbsp;&nbsp;POWER <b id="powerVal">50</b></span>
    <span class="spacer"></span>
    <span class="hud-cell score">P1 <b class="s1" id="scoreP1">0</b></span>
    <span class="hud-cell score">P2 <b class="s2" id="scoreP2">0</b></span>
    <button id="btnTune" title="Live tuning panel (T)">TUNE ⚙</button>
    <button id="btnAbout" title="How to play">?</button>
  </div>
  <div id="stage">
    <canvas id="gameCanvas"></canvas>
    <canvas id="fxCanvas"></canvas>
    <div id="winBanner" class="hidden"></div>
    <div id="toast"></div>

    <!-- TUNING PANEL -->
    <div id="tunePanel">
      <button id="tuneClose">✕</button>
      <div class="tune-head">
        <h2>LIVE TUNING CONSOLE</h2>
        <span id="liveBadge"><span class="dot"></span>LIVE TUNING ACTIVE</span>
      </div>
      <div class="tune-body" id="tuneBody"></div>
      <div class="tune-foot">
        <button id="btnResetCfg">RESET DEFAULTS</button>
        <button id="btnCloseTune">CLOSE</button>
      </div>
    </div>

    <!-- TITLE -->
    <div class="overlay" id="titleScreen">
      <div class="card">
        <h1>GORILLA<span class="bz">Z</span></h1>
        <div class="tagline">★ BANANA-BOMB ARTILEERY · EST. 1994 ★</div>
        <p>Two gorillas. One skyline. Exploding bananas.<br>
        Take turns lobbing the fruit across the rooftops —<br>
        splash or direct hit drops your rival. First fella down loses.</p>
        <div class="keys">
          <span class="kbd">◄</span><span class="kbd">►</span> angle &nbsp;·&nbsp;
          <span class="kbd">▲</span><span class="kbd">▼</span> power (or hold <span class="kbd">SHIFT</span> for fine-tune)<br>
          <span class="kbd">SPACE</span> / <span class="kbd">ENTER</span> throw banana &nbsp;·&nbsp;
          <span class="kbd">T</span> live tuning &nbsp;·&nbsp; <span class="kbd">M</span> sound
        </div>
        <div class="btnrow">
          <button class="gold big" id="btnStart">START MATCH</button>
          <button class="big" id="btnTuneTitle">TUNE ⚙</button>
        </div>
      </div>
    </div>

    <!-- REMATCH -->
    <div class="overlay hidden" id="rematchScreen">
      <div class="card">
        <h1 id="rematchTitle" style="font-size:34px;">PLAYER 1 WINS!</h1>
        <p id="rematchSub">Opponent is a banana pancake.</p>
        <div class="btnrow">
          <button class="gold big" id="btnRematch">NEW MATCH (NEW SKYLINE)</button>
          <button class="big" id="btnTuneRematch">TUNE ⚙</button>
        </div>
      </div>
    </div>

    <!-- ABOUT -->
    <div class="overlay hidden" id="aboutScreen">
      <div class="card" id="aboutCard">
        <h2>HOW TO PLAY</h2>
        <p><b>GOAL:</b> Blast the other gorilla off the roof. A banana that lands within the explosion radius — or bonks the gorilla directly — ends the match.</p>
        <p><b>PHYSICS:</b> Classic parabolic arcs with gravity, plus a horizontal wind force that shifts after every throw. Watch the wind meter in the top bar — strong wind bends the banana hard. Buildings are indestructible; the banana stops at the first surface it hits.</p>
        <p><b>CONTROLS:</b> <span class="kbd">◄</span><span class="kbd">►</span> angle · <span class="kbd">▲</span><span class="kbd">▼</span> power · <span class="kbd">SPACE</span> fire · <span class="kbd">T</span> tuning (works between turns and mid-flight — values apply to the <i>next</i> launch) · <span class="kbd">M</span> mute.</p>
        <p><b>LIVE TUNING:</b> every physics &amp; generation constant is editable in-flight: blast radius, gravity, wind range, building count &amp; heights, power scale, and splash falloff. Green-highlighted values are live edits; RESET DEFAULTS restores the classic reference values.</p>
        <div class="btnrow" style="margin-top:14px;"><button class="gold big" id="btnCloseAbout">GOT IT</button></div>
      </div>
    </div>
  </div>
</div>

<script>
"use strict";
/* =====================================================================
   GORILLAZ — two-player turn-based artillery, retro arcade edition
   Single-file build. All constants live in CFG (live-tunable).
   ===================================================================== */

/* ---------------- deterministic PRNG (mulberry32) ---------------- */
function mulberry32(a){
  return function(){
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
function hashStr(s){
  let h = 2166136261;
  for (let i = 0; i < s.length; i++){ h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}
const clamp = (v,a,b)=> v < a ? a : (v > b ? b : v);
const lerp  = (a,b,t)=> a + (b-a)*t;

/* ---------------- live configuration ---------------- */
const DEFAULTS = {
  blastRadius: 72,        // px — circular damage radius
  gravity: 900,           // px/s^2
  windMin: 0,             // min wind magnitude (px/s^2)
  windMax: 120,           // max wind magnitude
  windShifts: true,       // wind may change each turn
  buildingCount: 10,      // buildings per skyline (spec 8–14)
  bldgMinH: 120,          // min building height (px)
  bldgMaxH: 420,          // max building height (px)
  powerMin: 10,           // power scale lower bound
  powerMax: 100,          // power scale upper bound
  falloff: false,         // false = hard blast edge (classic); true = falloff
  falloffPct: 50          // with falloff: % damage at radius edge
};
let cfg = { ...DEFAULTS };

const W = 1280, H = 720;
const GROUND = 660;                 // ground surface y (px)
const MAX_SPEED = 1500;             // px/s at power = powerMax
const BULLET_R = 9;                 // banana radius
const GORILLA_R = 16;               // gorilla hit radius
const STEP = 1/240;                 // fixed physics substep (deterministic)
const ANGLE_MIN = 5, ANGLE_MAX = 175;
const PALETTE = ['#c04a4a','#3f7fbf','#4aa86a','#b0813a','#8a5fbf','#bf4a8a','#3aa8a8','#c9a227','#7a8aa8','#d06a3a'];
const P_COLORS = [
  { body:'#e04545', dark:'#8f1f1f', light:'#ff8d7d', name:'PLAYER 1' },
  { body:'#4a86e8', dark:'#1f4a9e', light:'#8db8ff', name:'PLAYER 2' }
];
const LOSS_REASONS = ['is a banana pancake.','got flung into the stratosphere.','is napping on the sidewalk.','went for a swim. In the crater.','has a very round day.'];

/* ---------------- state ---------------- */
const state = {
  phase: 'title',          // title | aiming | flight | exploding | over
  turn: 0,                 // 0 or 1
  scores: [0, 0],
  angle: [45, 135],
  power: [50, 50],
  wind: 0,                 // signed px/s^2 (+ = right)
  windDisp: 0,             // smoothed display value
  buildings: [],
  gorillas: [],
  seed: 0,
  matchNum: 0,
  banana: null,
  trail: [],
  explosions: [],
  particles: [],
  scorches: [],
  shake: 0,
  time: 0,
  lastWinner: -1,
  winnerReason: '',
  sunAngle: 0,
  muted: false,
  confetti: []
};

/* ---------------- canvases ---------------- */
const gameCanvas = document.getElementById('gameCanvas');
const fxCanvas   = document.getElementById('fxCanvas');
const gctx = gameCanvas.getContext('2d');
const fctx = fxCanvas.getContext('2d');
for (const c of [gameCanvas, fxCanvas]){
  c.width = W; c.height = H;
}

/* ---------------- audio (WebAudio, lazy init) ---------------- */
const sfx = (() => {
  let actx = null, master = null, windGain = null, windFilter = null, windSrc = null, windNoise = null;
  let unlocked = false;
  function ensure(){
    if (actx) return true;
    if (!unlocked) return false; // no AudioContext until a user gesture
    try{
      actx = new (window.AudioContext || window.webkitAudioContext)();
      master = actx.createGain(); master.gain.value = 0.5; master.connect(actx.destination);
      // wind loop: filtered noise
      const len = actx.sampleRate * 2;
      windNoise = actx.createBuffer(1, len, actx.sampleRate);
      const d = windNoise.getChannelData(0);
      for (let i = 0; i < len; i++) d[i] = Math.random()*2 - 1;
      windSrc = actx.createBufferSource(); windSrc.buffer = windNoise; windSrc.loop = true;
      windFilter = actx.createBiquadFilter(); windFilter.type = 'lowpass'; windFilter.frequency.value = 300;
      windGain = actx.createGain(); windGain.gain.value = 0;
      windSrc.connect(windFilter); windFilter.connect(windGain); windGain.connect(master);
      windSrc.start();
      return true;
    }catch(e){ return false; }
  }
  function tone(freq, dur, type, vol, slideTo){
    if (!ensure() || state.muted) return;
    const o = actx.createOscillator(), g = actx.createGain();
    o.type = type; o.frequency.value = freq;
    if (slideTo) o.frequency.exponentialRampToValueAtTime(Math.max(20, slideTo), actx.currentTime + dur);
    g.gain.value = vol;
    g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + dur);
    o.connect(g); g.connect(master);
    o.start(); o.stop(actx.currentTime + dur);
  }
  function noiseBurst(dur, vol, freq){
    if (!ensure() || state.muted) return;
    const n = actx.createBufferSource(); n.buffer = windNoise;
    const f = actx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = freq;
    const g = actx.createGain(); g.gain.value = vol;
    g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + dur);
    n.connect(f); f.connect(g); g.connect(master);
    n.start(); n.stop(actx.currentTime + dur);
  }
  return {
    unlock(){
      unlocked = true;
      if (ensure() && actx.state === 'suspended') actx.resume();
    },
    launch(){ tone(190, 0.28, 'square', 0.22, 640); noiseBurst(0.12, 0.12, 2400); },
    explosion(){ noiseBurst(0.55, 0.5, 900); tone(70, 0.4, 'sine', 0.5, 30); },
    boing(){ tone(240, 0.12, 'sine', 0.18, 120); },
    turn(){ tone(660, 0.07, 'square', 0.08); setTimeout(()=>tone(880,0.07,'square',0.08), 80); },
    win(){ [523,659,784,1047].forEach((f,i)=> setTimeout(()=>tone(f, .22, 'square', .16), i*130)); setTimeout(()=>noiseBurst(0.5,0.1,600), 560); },
    lose(){ [392,330,262,196].forEach((f,i)=> setTimeout(()=>tone(f, .25, 'sawtooth', .12), i*150)); },
    setWind(mag){
      if (!ensure()) return;
      const t = actx.currentTime;
      windGain.gain.cancelScheduledValues(t);
      windGain.gain.linearRampToValueAtTime(state.muted ? 0 : clamp(mag/140, 0, 1) * 0.09, t + 0.8);
      windFilter.frequency.cancelScheduledValues(t);
      windFilter.frequency.linearRampToValueAtTime(180 + clamp(mag/140,0,1)*900, t + 0.8);
    },
    setMuted(m){
      state.muted = m;
      if (master) master.gain.value = m ? 0 : 0.5;
    }
  };
})();

/* ---------------- city generation (seeded) ---------------- */
function genCity(seed){
  const rnd = mulberry32(seed);
  const n = clamp(Math.round(cfg.buildingCount), 4, 20);
  const bldgs = [];
  let x = 16;
  const rightLimit = W - 16;
  const targetGap = 12;
  // precompute widths
  const widths = [];
  let totalW = 0;
  for (let i = 0; i < n; i++){
    const w = 70 + Math.floor(rnd() * 75);
    widths.push(w); totalW += w;
  }
  const gap = clamp((rightLimit - x - totalW) / (n - 1), 4, 60);
  for (let i = 0; i < n; i++){
    let h = cfg.bldgMinH + rnd() * (cfg.bldgMaxH - cfg.bldgMinH);
    h = clamp(h, 60, GROUND - 40);
    const b = {
      x, w: widths[i], h,
      y: GROUND - h,
      color: PALETTE[Math.floor(rnd() * PALETTE.length)],
      winSeed: Math.floor(rnd() * 1e9),
      deco: rnd() < 0.45 ? Math.floor(rnd() * 3) : -1,
      windows: []
    };
    // window grid
    const cols = Math.max(1, Math.floor((b.w - 14) / 22));
    const rows = Math.max(1, Math.floor((b.h - 20) / 26));
    const wx0 = b.x + (b.w - cols * 22) / 2 + 4;
    for (let r = 0; r < rows; r++){
      for (let c = 0; c < cols; c++){
        b.windows.push({ x: wx0 + c * 22, y: b.y + 12 + r * 26, lit: rnd() < 0.3 });
      }
    }
    bldgs.push(b);
    x += b.w + gap;
  }
  // normalize to fill width exactly
  const overflow = (bldgs[n-1].x + bldgs[n-1].w) - rightLimit;
  if (overflow > 0){
    const per = overflow / n;
    for (const b of bldgs){ b.w = Math.max(40, b.w - per); }
    let xx = 16;
    for (const b of bldgs){ b.x = xx; xx += b.w + gap; }
  }
  return bldgs;
}

function placeGorillas(bldgs){
  const n = bldgs.length;
  const mid = W / 2;
  // left half = buildings whose center < mid, right half the rest
  let left = bldgs.filter(b => b.x + b.w/2 < mid);
  let right = bldgs.filter(b => b.x + b.w/2 >= mid);
  if (left.length === 0)  left = bldgs.slice(0, 1);
  if (right.length === 0) right = bldgs.slice(n - 1);
  const lb = left[Math.floor(Math.random() * left.length)];
  const rb = right[Math.floor(Math.random() * right.length)];
  const mk = (b, side, idx) => ({
    idx, side,
    x: clamp(b.x + b.w/2, 26, W - 26),
    y: b.y,                 // stands on roof (feet at y)
    r: GORILLA_R,
    alive: true,
    face: side === 'left' ? 1 : -1,
    t: Math.random() * 10,
    bounce: 0,
    fly: 0,                 // launched off building
    vx: 0, vy: 0
  });
  const g1 = mk(lb, 'left', 0);
  const g2 = mk(rb, 'right', 1);
  // ensure they aren't trivially in blast range of each other (spec: neither starts in blast range)
  // if same building or too close, push apart to opposite ends
  const minGap = cfg.blastRadius * 2 + 30;
  if (Math.abs(g1.x - g2.x) < minGap){
    if (g1.x < g2.x){ g1.x = lb.x + 14; g2.x = rb.x + rb.w - 14; }
    else { g1.x = lb.x + lb.w - 14; g2.x = rb.x + 14; }
  }
  return [g1, g2];
}

function rollWind(){
  const rnd = Math.random;
  let mag;
  if (cfg.windShifts){
    mag = cfg.windMin + rnd() * (cfg.windMax - cfg.windMin);
  } else {
    mag = state.windMag || (cfg.windMin + rnd() * (cfg.windMax - cfg.windMin));
  }
  const dir = rnd() < 0.5 ? -1 : 1;
  const signed = dir * mag;
  state.wind = Math.round(signed * 10) / 10;
  state.windMag = Math.abs(state.wind);
  sfx.setWind(Math.abs(state.wind));
  toast('WIND ' + fmtWind(state.wind));
}
function fmtWind(w){
  const m = Math.abs(w);
  if (m < 0.5) return 'CALM';
  const label = m > 90 ? 'STRONG' : (m > 45 ? 'BREEZY' : 'LIGHT');
  return label + ' ' + m.toFixed(0) + (w > 0 ? ' →' : ' ←');
}

/* ---------------- match flow ---------------- */
function startMatch(){
  state.matchNum++;
  state.seed = (Math.random() * 1e9) >>> 0;
  state.buildings = genCity(state.seed);
  state.gorillas = placeGorillas(state.buildings);
  state.scorches = [];
  state.explosions = [];
  state.particles = [];
  state.trail = [];
  state.banana = null;
  state.confetti = [];
  state.lastWinner = -1;
  // fresh wind per match
  state.wind = 0; state.windDisp = 0;
  rollWind();
  state.turn = 0;
  state.phase = 'aiming';
  state.sunAngle = Math.random() * Math.PI * 2;
  document.getElementById('titleScreen').classList.add('hidden');
  document.getElementById('rematchScreen').classList.add('hidden');
  document.getElementById('winBanner').classList.add('hidden');
  sfx.turn();
  toast('MATCH #' + state.matchNum + ' — ' + P_COLORS[0].name + ' throws first');
  updateHUD();
}

function endMatch(winner, reason){
  state.phase = 'over';
  state.lastWinner = winner;
  state.winnerReason = reason;
  state.scores[winner]++;
  const loser = state.gorillas[1 - winner];
  loser.alive = false;
  loser.fly = 0.001;
  loser.vx = (loser.x < winner >= 0 ? 1 : 1) * 0; // set below
  loser.vx = (loser.x >= state.gorillas[winner].x ? 1 : -1) * 260;
  loser.vy = -620;
  sfx.win();
  sfx.lose();
  // confetti
  for (let i = 0; i < 90; i++){
    state.confetti.push({
      x: W/2 + (Math.random()-0.5)*300, y: H*0.28,
      vx: (Math.random()-0.5)*420, vy: -Math.random()*380 - 60,
      c: P_COLORS[winner].body, r: 3 + Math.random()*4,
      rot: Math.random()*6, vr: (Math.random()-0.5)*12, life: 2.6 + Math.random()
    });
  }
  const banner = document.getElementById('winBanner');
  banner.innerHTML = '<div class="wname" style="color:' + P_COLORS[winner].body + '; text-shadow:0 0 18px ' + P_COLORS[winner].body + ';">' +
    P_COLORS[winner].name + ' WINS!</div><div class="wsub">OPPONENT ' + LOSS_REASONS[Math.floor(Math.random()*LOSS_REASONS.length)] + '</div>';
  banner.classList.remove('hidden');
  setTimeout(()=>{
    document.getElementById('rematchTitle').textContent = P_COLORS[winner].name + ' WINS!';
    document.getElementById('rematchSub').textContent = 'Opponent ' + LOSS_REASONS[Math.floor(Math.random()*LOSS_REASONS.length)];
    document.getElementById('rematchScreen').classList.remove('hidden');
  }, 1700);
  updateHUD();
}

function passTurn(){
  state.turn = 1 - state.turn;
  state.phase = 'aiming';
  rollWind();
  sfx.turn();
  toast(P_COLORS[state.turn].name + ' — set angle & power, then SPACE');
  updateHUD();
}

/* ---------------- banana physics ---------------- */
function fire(){
  if (state.phase !== 'aiming') return;
  const g = state.gorillas[state.turn];
  const a = state.angle[state.turn] * Math.PI / 180;
  const dir = g.side === 'left' ? 1 : -1;
  const speed = lerp(0, MAX_SPEED, (state.power[state.turn] - cfg.powerMin) / Math.max(1, cfg.powerMax - cfg.powerMin));
  const px = g.x + dir * 22;
  const py = g.y - 24;
  state.banana = {
    x: px, y: py,
    vx: Math.cos(a) * speed * 1,
    vy: -Math.sin(a) * speed,
    rot: 0
  };
  state.trail = [];
  state.phase = 'flight';
  sfx.launch();
  state.shake = Math.max(state.shake, 3);
  updateHUD();
}

function segSeg(x1,y1,x2,y2, x3,y3,x4,y4){
  const d = (x2-x1)*(y4-y3) - (y2-y1)*(x4-x3);
  if (Math.abs(d) < 1e-9) return false;
  const t = ((x3-x1)*(y4-y3) - (y3-y1)*(x4-x3)) / d;
  const u = ((x3-x1)*(y2-y1) - (y3-y1)*(x2-x1)) / d;
  return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function circleInRect(x, y, r){
  const cx = clamp(x, r.x, r.x + r.w), cy = clamp(y, r.y, r.y + r.h);
  const dx = x - cx, dy = y - cy;
  return dx*dx + dy*dy <= BULLET_R*BULLET_R;
}
function segHitsRect(x1,y1,x2,y2, r){
  // fast reject on expanded bounding box
  const minX = Math.min(x1,x2) - BULLET_R, maxX = Math.max(x1,x2) + BULLET_R;
  const minY = Math.min(y1,y2) - BULLET_R, maxY = Math.max(y1,y2) + BULLET_R;
  if (maxX < r.x || minX > r.x + r.w || maxY < r.y || minY > r.y + r.h) return false;
  if (circleInRect(x2, y2, r)) return true;
  const L = r.x, T = r.y, R = r.x + r.w, B = r.y + r.h;
  return segSeg(x1,y1,x2,y2, L,T,R,T) || segSeg(x1,y1,x2,y2, R,T,R,B) ||
         segSeg(x1,y1,x2,y2, R,B,L,B) || segSeg(x1,y1,x2,y2, L,B,L,T);
}

function bananaHitsGorilla(b, g){
  if (!g.alive) return false;
  const cx = g.x, cy = g.y - 20;
  const dx = b.x - cx, dy = b.y - cy;
  const rr = BULLET_R + g.r + 4;
  return dx*dx + dy*dy <= rr*rr;
}

function stepBanana(dt){
  const b = state.banana;
  const sub = 2;
  const h = dt / sub;
  for (let s = 0; s < sub; s++){
    const px = b.x, py = b.y;
    b.vy += cfg.gravity * h;
    b.vx += state.wind * h;
    b.x += b.vx * h;
    b.y += b.vy * h;
    b.rot += (b.vx * 0.02) * h * 60;
    // direct hit check
    const target = state.gorillas[1 - state.turn];
    if (bananaHitsGorilla(b, target)){
      explode(b.x, b.y, true);
      return;
    }
    // ground
    if (b.y + BULLET_R >= GROUND){
      b.y = GROUND - BULLET_R;
      explode(b.x, b.y, false);
      return;
    }
    // buildings — first collision wins, banana stops
    for (const r of state.buildings){
      if (segHitsRect(px, py, b.x, b.y, r)){
        explode(b.x, b.y, false);
        return;
      }
    }
    // off-screen sides / top safety
    if (b.x < -60 || b.x > W + 60 || b.y < -400){
      explode(b.x, Math.min(b.y, GROUND - 1), false);
      return;
    }
  }
  const lastTr = state.trail[state.trail.length - 1];
  if (!lastTr || Math.hypot(b.x - lastTr.x, b.y - lastTr.y) > 7){
    state.trail.push({ x: b.x, y: b.y, life: 0.55 });
    if (state.trail.length > 110) state.trail.shift();
  }
}

function explode(x, y, directHit){
  state.banana = null;
  state.phase = 'exploding';
  state.explosions.push({ x, y, r: 0, maxR: cfg.blastRadius, t: 0, dur: 0.55, direct: directHit });
  state.scorches.push({ x, y, r: cfg.blastRadius * 0.55 });
  if (state.scorches.length > 24) state.scorches.shift();
  // dust particles
  const n = 26;
  for (let i = 0; i < n; i++){
    const a = Math.random() * Math.PI * 2;
    const sp = 60 + Math.random() * 240;
    state.particles.push({
      x, y, vx: Math.cos(a)*sp, vy: Math.sin(a)*sp - 80,
      life: 0.5 + Math.random()*0.6, maxLife: 1.1,
      c: Math.random() < 0.5 ? '#c9b18a' : (Math.random() < 0.5 ? '#8a7a5c' : '#ffd23f'),
      r: 2 + Math.random()*4
    });
  }
  state.shake = 9;
  sfx.explosion();
  // damage check — deterministic given cfg
  const target = state.gorillas[1 - state.turn];
  const cx = target.x, cy = target.y - 20;
  const d = Math.hypot(x - cx, y - cy);
  let dmg = 0;
  if (d < BULLET_R + target.r + 4) dmg = 1;
  else if (d <= cfg.blastRadius){
    if (!cfg.falloff) dmg = 1;
    else {
      const t = d / cfg.blastRadius;
      dmg = lerp(1, cfg.falloffPct / 100, t);
      if (dmg < 0.5) dmg = 0;
    }
  }
  state.pendingResult = { dmg, target, x, y };
  // small bounce feedback if it hit our own gorilla (cosmetic)
  if (d < BULLET_R + state.gorillas[state.turn].r + 6){
    state.gorillas[state.turn].bounce = 1;
    sfx.boing();
  }
  setTimeout(resolveExplosion, 620);
}

function resolveExplosion(){
  if (state.phase !== 'exploding') return;
  const { dmg, target } = state.pendingResult;
  if (dmg > 0 && target.alive){
    endMatch(state.turn, 'blast');
  } else {
    passTurn();
  }
}

/* ---------------- HUD ---------------- */
const el = id => document.getElementById(id);
function updateHUD(){
  const chip = el('turnChip');
  chip.textContent = state.phase === 'over'
    ? (state.lastWinner >= 0 ? P_COLORS[state.lastWinner].name + ' WINS' : 'MATCH OVER')
    : P_COLORS[state.turn].name + ' TURN';
  chip.className = 't' + (state.turn + 1);
  el('scoreP1').textContent = state.scores[0];
  el('scoreP2').textContent = state.scores[1];
  el('angleVal').textContent = Math.round(state.angle[state.turn]);
  el('powerVal').textContent = Math.round(state.power[state.turn]);
  const w = state.windDisp;
  el('windVal').textContent = Math.abs(w).toFixed(0);
  el('windArrow').textContent = Math.abs(w) < 0.5 ? '•' : (w > 0 ? '→' : '←');
  el('windArrow').style.color = Math.abs(w) > 90 ? 'var(--amber)' : 'var(--green)';
}

let toastTimer = null;
function toast(msg){
  const t = el('toast');
  t.textContent = msg;
  t.style.opacity = 1;
  clearTimeout(toastTimer);
  toastTimer = setTimeout(()=>{ t.style.opacity = 0; }, 2200);
}

/* ---------------- rendering ---------------- */
function draw(){
  const t = state.time;
  const ctx = gctx;
  ctx.setTransform(1,0,0,1,0,0);
  ctx.clearRect(0,0,W,H);
  // camera shake
  const sh = state.shake;
  const sx = (Math.random()-0.5) * sh, sy = (Math.random()-0.5) * sh;
  ctx.save();
  ctx.translate(sx, sy);

  /* --- sky --- */
  const sky = ctx.createLinearGradient(0, 0, 0, GROUND);
  sky.addColorStop(0, '#0d1038');
  sky.addColorStop(0.55, '#33255e');
  sky.addColorStop(0.85, '#7a3a68');
  sky.addColorStop(1, '#c65a4a');
  ctx.fillStyle = sky;
  ctx.fillRect(-20, -20, W+40, GROUND+20);
  // stars
  const starRnd = mulberry32(777);
  ctx.fillStyle = 'rgba(255,255,255,.7)';
  for (let i = 0; i < 46; i++){
    const x = starRnd()*W, y = starRnd()*(GROUND*0.55);
    const tw = 0.4 + 0.6*Math.abs(Math.sin(t*1.5 + i*1.7));
    ctx.globalAlpha = tw * 0.8;
    ctx.fillRect(x, y, 2, 2);
  }
  ctx.globalAlpha = 1;
  // moon (upper right, always visible)
  const sunX = W*0.86, sunY = 120 + Math.sin(state.sunAngle*0.3)*4;
  const sg = ctx.createRadialGradient(sunX, sunY, 8, sunX, sunY, 150);
  sg.addColorStop(0, 'rgba(210,225,255,.35)');
  sg.addColorStop(1, 'rgba(210,225,255,0)');
  ctx.fillStyle = sg;
  ctx.fillRect(sunX-160, sunY-160, 320, 320);
  ctx.fillStyle = '#e8ecf5';
  ctx.beginPath(); ctx.arc(sunX, sunY, 34, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = 'rgba(150,160,190,.5)';
  ctx.beginPath(); ctx.arc(sunX-10, sunY-8, 7, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(sunX+12, sunY+10, 5, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(sunX+2, sunY+18, 3.5, 0, Math.PI*2); ctx.fill();
  // clouds (drift with wind, wrap)
  const cloudRnd = mulberry32(4242);
  ctx.fillStyle = 'rgba(255,214,224,.16)';
  for (let i = 0; i < 6; i++){
    const cw = 90 + cloudRnd()*120, ch = 18 + cloudRnd()*14;
    let cx = (cloudRnd()*W + t * (8 + state.windDisp*0.12) * (i%2?1:0.6)) % (W + 200);
    if (cx < -100) cx += W + 200;
    const cy = 60 + cloudRnd()*200;
    ctx.beginPath();
    ctx.ellipse(cx, cy, cw/2, ch/2, 0, 0, Math.PI*2);
    ctx.ellipse(cx - cw*0.28, cy+6, cw/3.4, ch/2.4, 0, 0, Math.PI*2);
    ctx.ellipse(cx + cw*0.26, cy+4, cw/3.2, ch/2.6, 0, 0, Math.PI*2);
    ctx.fill();
  }

  /* --- ground --- */
  ctx.fillStyle = '#20242e';
  ctx.fillRect(-20, GROUND, W+40, H-GROUND+20);
  ctx.fillStyle = '#2e3442';
  ctx.fillRect(-20, GROUND, W+40, 6);
  // road dashes
  ctx.fillStyle = '#4a5268';
  for (let x = 0; x < W; x += 48) ctx.fillRect(x + 8, GROUND + 22, 26, 4);
  // scorch marks
  for (const s of state.scorches){
    ctx.fillStyle = 'rgba(15,12,10,.55)';
    ctx.beginPath();
    ctx.ellipse(s.x, Math.min(s.y, GROUND-2), s.r, s.r*0.32, 0, 0, Math.PI*2);
    ctx.fill();
  }

  /* --- buildings --- */
  for (const b of state.buildings){
    ctx.fillStyle = b.color;
    ctx.fillRect(b.x, b.y, b.w, b.h);
    // side shading + top highlight
    ctx.fillStyle = 'rgba(0,0,0,.28)';
    ctx.fillRect(b.x + b.w - 7, b.y, 7, b.h);
    ctx.fillStyle = 'rgba(255,255,255,.22)';
    ctx.fillRect(b.x, b.y, b.w, 4);
    ctx.fillStyle = 'rgba(0,0,0,.35)';
    ctx.fillRect(b.x - 2, b.y + b.h - 3, b.w + 4, 3);
    // windows
    for (const w of b.windows){
      ctx.fillStyle = w.lit ? '#ffd97a' : 'rgba(10,14,26,.55)';
      ctx.fillRect(w.x, w.y, 10, 14);
    }
    // roof decorations
    if (b.deco === 0){
      ctx.fillStyle = 'rgba(0,0,0,.3)';
      ctx.fillRect(b.x + b.w/2 - 3, b.y - 26, 6, 26);
      ctx.fillStyle = '#ff5d5d';
      const bl = 0.5 + 0.5*Math.sin(t*3 + b.x);
      ctx.globalAlpha = 0.4 + 0.6*bl;
      ctx.beginPath(); ctx.arc(b.x + b.w/2, b.y - 28, 4, 0, Math.PI*2); ctx.fill();
      ctx.globalAlpha = 1;
    } else if (b.deco === 1){
      ctx.fillStyle = 'rgba(0,0,0,.3)';
      ctx.fillRect(b.x + 8, b.y - 14, b.w*0.3, 14);
    } else if (b.deco === 2){
      ctx.strokeStyle = 'rgba(0,0,0,.35)'; ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.moveTo(b.x + b.w*0.2, b.y); ctx.lineTo(b.x + b.w*0.5, b.y - 22); ctx.lineTo(b.x + b.w*0.8, b.y);
      ctx.stroke();
    }
  }

  /* --- aim preview (aiming phase) --- */
  if (state.phase === 'aiming'){
    const g = state.gorillas[state.turn];
    const a = state.angle[state.turn] * Math.PI/180;
    const dir = g.side === 'left' ? 1 : -1;
    const speed = lerp(0, MAX_SPEED, (state.power[state.turn] - cfg.powerMin) / Math.max(1, cfg.powerMax - cfg.powerMin));
    const px = g.x + dir*22, py = g.y - 24;
    let vx = Math.cos(a)*speed, vy = -Math.sin(a)*speed;
    let x = px, y = py;
    const dt = 1/45;
    ctx.fillStyle = P_COLORS[state.turn].light;
    for (let i = 0; i < 46; i++){
      vy += cfg.gravity*dt; vx += state.wind*dt;
      x += vx*dt; y += vy*dt;
      if (y > GROUND - 2) break;
      let hitB = false;
      for (const r of state.buildings){
        if (x > r.x - 4 && x < r.x + r.w + 4 && y > r.y - 4){ hitB = true; break; }
      }
      if (hitB) break;
      if (i % 3 === 0){
        const al = 1 - i/46;
        ctx.globalAlpha = al * 0.9;
        ctx.fillRect(x - 2.5, y - 2.5, 5, 5);
      }
    }
    ctx.globalAlpha = 1;
    // aim arrow
    ctx.strokeStyle = P_COLORS[state.turn].body;
    ctx.lineWidth = 4; ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(px, py);
    ctx.lineTo(px + Math.cos(a)*46, py - Math.sin(a)*46);
    ctx.stroke();
    // power ring
    const pFrac = (state.power[state.turn] - cfg.powerMin) / Math.max(1, cfg.powerMax - cfg.powerMin);
    ctx.lineWidth = 6;
    ctx.strokeStyle = 'rgba(255,255,255,.15)';
    ctx.beginPath(); ctx.arc(g.x, g.y - 20, 34, 0, Math.PI*2); ctx.stroke();
    ctx.strokeStyle = P_COLORS[state.turn].body;
    ctx.beginPath(); ctx.arc(g.x, g.y - 20, 34, -Math.PI/2, -Math.PI/2 + pFrac*Math.PI*2); ctx.stroke();
  }

  /* --- gorillas --- */
  for (const g of state.gorillas){
    drawGorilla(ctx, g, t);
  }

  /* --- banana + trail --- */
  if (state.banana){
    for (let i = 0; i < state.trail.length; i++){
      const tr = state.trail[i];
      const al = (i / state.trail.length) * 0.5;
      ctx.globalAlpha = al;
      ctx.fillStyle = '#ffe066';
      ctx.beginPath(); ctx.arc(tr.x, tr.y, 3 + (i/state.trail.length)*4, 0, Math.PI*2); ctx.fill();
    }
    ctx.globalAlpha = 1;
    drawBanana(ctx, state.banana.x, state.banana.y, state.banana.rot);
  }

  /* --- explosions --- */
  for (const e of state.explosions){
    const p = e.t / e.dur;
    const r = e.maxR * (1 - Math.pow(1 - Math.min(1, p*1.6), 3));
    const grd = ctx.createRadialGradient(e.x, e.y, r*0.1, e.x, e.y, Math.max(4, r));
    grd.addColorStop(0, 'rgba(255,240,180,' + (1-p) + ')');
    grd.addColorStop(0.45, 'rgba(255,150,50,' + (1-p)*0.85 + ')');
    grd.addColorStop(0.8, 'rgba(200,60,30,' + (1-p)*0.5 + ')');
    grd.addColorStop(1, 'rgba(60,20,10,0)');
    ctx.fillStyle = grd;
    ctx.beginPath(); ctx.arc(e.x, e.y, Math.max(4, r), 0, Math.PI*2); ctx.fill();
    // shockwave ring
    ctx.strokeStyle = 'rgba(255,230,160,' + (1-p)*0.8 + ')';
    ctx.lineWidth = 3;
    ctx.beginPath(); ctx.arc(e.x, e.y, r + 8 + p*26, 0, Math.PI*2); ctx.stroke();
    // blast-radius hint at peak
    if (p > 0.25 && p < 0.85){
      ctx.setLineDash([6,7]);
      ctx.strokeStyle = 'rgba(255,255,255,' + (0.5 - Math.abs(p-0.55)) + ')';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.arc(e.x, e.y, e.maxR, 0, Math.PI*2); ctx.stroke();
      ctx.setLineDash([]);
    }
  }

  /* --- particles --- */
  for (const p of state.particles){
    ctx.globalAlpha = clamp(p.life / p.maxLife, 0, 1);
    ctx.fillStyle = p.c;
    ctx.fillRect(p.x - p.r/2, p.y - p.r/2, p.r, p.r);
  }
  ctx.globalAlpha = 1;

  /* --- wind streaks --- */
  const wm = Math.abs(state.windDisp);
  if (wm > 3){
    ctx.strokeStyle = 'rgba(255,255,255,.14)';
    ctx.lineWidth = 1.5;
    const dir = state.windDisp > 0 ? 1 : -1;
    for (let i = 0; i < 10; i++){
      const y = 40 + ((i * 61) % 560);
      const x = ((t * (120 + wm*2.2) * dir + i * 173) % (W + 120) + W + 120) % (W + 120) - 60;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x + dir * (10 + wm*0.4), y + 2);
      ctx.stroke();
    }
  }

  ctx.restore();

  /* --- FX layer: confetti (no shake) --- */
  fctx.setTransform(1,0,0,1,0,0);
  fctx.clearRect(0,0,W,H);
  for (const c of state.confetti){
    fctx.save();
    fctx.translate(c.x, c.y);
    fctx.rotate(c.rot);
    fctx.fillStyle = c.c;
    fctx.globalAlpha = clamp(c.life, 0, 1);
    fctx.fillRect(-c.r/2, -c.r/2, c.r, c.r*0.6);
    fctx.restore();
  }
  fctx.globalAlpha = 1;

  /* --- scanlines / vignette --- */
  ctx.setTransform(1,0,0,1,0,0);
  ctx.fillStyle = 'rgba(0,0,0,.05)';
  for (let y = 0; y < H; y += 4) ctx.fillRect(0, y, W, 2);
  const vg = ctx.createRadialGradient(W/2, H/2, H*0.45, W/2, H/2, H*0.85);
  vg.addColorStop(0, 'rgba(0,0,0,0)');
  vg.addColorStop(1, 'rgba(0,0,0,.32)');
  ctx.fillStyle = vg;
  ctx.fillRect(0,0,W,H);
}

function drawGorilla(ctx, g, t){
  const col = P_COLORS[g.idx];
  const x = g.x, y = g.y;
  const dead = !g.alive && g.fly > 0;
  const bounce = g.bounce > 0 ? Math.sin(g.bounce * Math.PI) * 6 : 0;
  const bob = dead ? 0 : Math.sin(t*2.4 + g.idx*3) * 2;
  ctx.save();
  ctx.translate(x, y - bounce);
  if (dead){
    ctx.rotate(g.rot || 0);
  }
  const s = 1; // scale
  // shadow
  if (!dead){
    ctx.fillStyle = 'rgba(0,0,0,.3)';
    ctx.beginPath(); ctx.ellipse(0, -1, 20, 5, 0, 0, Math.PI*2); ctx.fill();
  }
  ctx.translate(0, bob);
  // legs
  ctx.fillStyle = col.dark;
  ctx.fillRect(-14*s, -12*s, 10*s, 12*s);
  ctx.fillRect(4*s, -12*s, 10*s, 12*s);
  // body
  ctx.fillStyle = col.body;
  ctx.fillRect(-16*s, -34*s, 32*s, 24*s);
  // chest
  ctx.fillStyle = col.light;
  ctx.fillRect(-9*s, -28*s, 18*s, 15*s);
  // arms
  const armLift = (state.phase === 'aiming' && state.turn === g.idx) ? Math.sin(t*4)*3 + 4 : 0;
  ctx.fillStyle = col.body;
  ctx.fillRect(-24*s, -34*s + 2, 9*s, 20*s - armLift);
  ctx.fillRect(15*s, -34*s + 2, 9*s, 20*s - armLift);
  // hands
  ctx.fillStyle = col.dark;
  ctx.fillRect(-24*s, -16*s - armLift, 9*s, 6*s);
  ctx.fillRect(15*s, -16*s - armLift, 9*s, 6*s);
  // head
  ctx.fillStyle = col.body;
  ctx.fillRect(-13*s, -52*s, 26*s, 20*s);
  // face
  ctx.fillStyle = col.light;
  ctx.fillRect(-9*s, -47*s, 18*s, 12*s);
  // eyes
  ctx.fillStyle = '#141420';
  if (dead){
    // X eyes
    ctx.strokeStyle = '#141420'; ctx.lineWidth = 2;
    for (const ex of [-5, 5]){
      ctx.beginPath();
      ctx.moveTo(ex-3, -45); ctx.lineTo(ex+3, -39);
      ctx.moveTo(ex+3, -45); ctx.lineTo(ex-3, -39);
      ctx.stroke();
    }
  } else {
    ctx.fillRect(-7*s + g.face*1.5, -45*s, 4*s, 5*s);
    ctx.fillRect(3*s + g.face*1.5, -45*s, 4*s, 5*s);
    // brow
    ctx.fillStyle = col.dark;
    ctx.fillRect(-9*s, -48*s, 18*s, 2.5*s);
  }
  // mouth
  ctx.fillStyle = '#141420';
  ctx.fillRect(-4*s, -40*s, 8*s, 2*s);
  // name tag
  ctx.restore();
  if (!dead){
    ctx.save();
    ctx.font = 'bold 11px "Courier New", monospace';
    ctx.textAlign = 'center';
    ctx.fillStyle = col.body;
    ctx.globalAlpha = 0.9;
    ctx.fillText('P' + (g.idx+1), x, y - 58);
    if (state.phase === 'aiming' && state.turn === g.idx){
      ctx.fillStyle = '#fff';
      ctx.globalAlpha = 0.5 + 0.5*Math.sin(t*5);
      const ty = y - 74 + Math.sin(t*4)*3;
      ctx.beginPath();
      ctx.moveTo(x-6, ty-8); ctx.lineTo(x+6, ty-8); ctx.lineTo(x, ty);
      ctx.closePath(); ctx.fill();
    }
    ctx.restore();
  }
}

function drawBanana(ctx, x, y, rot){
  ctx.save();
  ctx.translate(x, y);
  ctx.rotate(rot);
  // banana body (curved)
  ctx.fillStyle = '#ffd93b';
  ctx.beginPath();
  ctx.arc(0, 3, 10, Math.PI*1.05, Math.PI*1.95);
  ctx.arc(0, -2, 7, Math.PI*1.95, Math.PI*1.05, true);
  ctx.closePath();
  ctx.fill();
  ctx.strokeStyle = '#b8860b';
  ctx.lineWidth = 1.5;
  ctx.stroke();
  // tips
  ctx.fillStyle = '#8a5c00';
  ctx.fillRect(-10, 1, 4, 4);
  ctx.fillRect(6, 1, 4, 4);
  ctx.restore();
}

/* ---------------- main loop ---------------- */
let lastT = performance.now();
function frame(now){
  let dt = (now - lastT) / 1000;
  lastT = now;
  dt = Math.min(dt, 0.05);
  state.time += dt;
  state.shake = Math.max(0, state.shake - dt * 26);
  // wind display smoothing
  state.windDisp = lerp(state.windDisp, state.wind, 1 - Math.pow(0.001, dt));

  // physics with fixed substep accumulation (deterministic per turn)
  if (state.phase === 'flight' && state.banana){
    let acc = dt;
    while (acc > 0 && state.banana){
      const h = Math.min(acc, STEP);
      stepBanana(h);
      acc -= h;
    }
  }
  // explosions
  for (const e of state.explosions) e.t += dt;
  state.explosions = state.explosions.filter(e => e.t < e.dur + 0.15);
  // particles
  for (const p of state.particles){
    p.vy += 300*dt; p.x += p.vx*dt; p.y += p.vy*dt; p.life -= dt;
  }
  state.particles = state.particles.filter(p => p.life > 0);
  // trail fade
  for (const tr of state.trail) tr.life -= dt;
  state.trail = state.trail.filter(tr => tr.life > 0);
  // confetti
  for (const c of state.confetti){
    c.vy += 500*dt; c.x += c.vx*dt; c.y += c.vy*dt; c.rot += c.vr*dt; c.life -= dt;
  }
  state.confetti = state.confetti.filter(c => c.life > 0 && c.y < H + 30);
  // gorilla knock-off
  for (const g of state.gorillas){
    if (g.fly > 0){
      g.fly += dt;
      g.vy += 900*dt;
      g.x += g.vx*dt;
      g.y += g.vy*dt;
      g.rot = (g.rot || 0) + g.vx * 0.02;
      if (g.y > GROUND + 400) g.fly = -1; // off screen, hide
    }
    if (g.bounce > 0) g.bounce = Math.max(0, g.bounce - dt*2.2);
  }
  // sun drift
  state.sunAngle += dt * 0.02;

  draw();
  updateHUD();
  requestAnimationFrame(frame);
}

/* ---------------- input ---------------- */
window.addEventListener('keydown', (e) => {
  const k = e.key;
  sfx.unlock();
  if (k === 'm' || k === 'M'){
    sfx.setMuted(!state.muted);
    toast(state.muted ? 'SOUND OFF' : 'SOUND ON');
    return;
  }
  if (k === 't' || k === 'T'){
    toggleTune();
    return;
  }
  if (state.phase === 'title'){
    if (k === 'Enter' || k === ' ') startMatch();
    return;
  }
  if (state.phase === 'over'){
    if (k === 'Enter' || k === ' '){
      if (!document.getElementById('rematchScreen').classList.contains('hidden')) startMatch();
    }
    return;
  }
  if (state.phase !== 'aiming') return;
  const fine = e.shiftKey ? 1 : 5;
  const i = state.turn;
  let handled = true;
  switch (k){
    case 'ArrowLeft':  state.angle[i] = clamp(state.angle[i] - fine, ANGLE_MIN, ANGLE_MAX); break;
    case 'ArrowRight': state.angle[i] = clamp(state.angle[i] + fine, ANGLE_MIN, ANGLE_MAX); break;
    case 'ArrowUp':    state.power[i] = clamp(state.power[i] + fine, cfg.powerMin, cfg.powerMax); break;
    case 'ArrowDown':  state.power[i] = clamp(state.power[i] - fine, cfg.powerMin, cfg.powerMax); break;
    case ' ': case 'Enter': fire(); break;
    default: handled = false;
  }
  if (handled){ e.preventDefault(); updateHUD(); }
});

/* ---------------- buttons ---------------- */
el('btnStart').addEventListener('click', ()=>{ sfx.unlock(); startMatch(); });
el('btnRematch').addEventListener('click', ()=>{ sfx.unlock(); startMatch(); });
el('btnTune').addEventListener('click', toggleTune);
el('btnTuneTitle').addEventListener('click', ()=> openTune(true));
el('btnTuneRematch').addEventListener('click', ()=> openTune(true));
el('btnCloseTune').addEventListener('click', ()=> openTune(false));
el('tuneClose').addEventListener('click', ()=> openTune(false));
el('btnAbout').addEventListener('click', ()=> el('aboutScreen').classList.remove('hidden'));
el('btnCloseAbout').addEventListener('click', ()=> el('aboutScreen').classList.add('hidden'));
el('btnResetCfg').addEventListener('click', ()=>{
  cfg = { ...DEFAULTS };
  buildTuneUI();
  toast('DEFAULTS RESTORED');
});

/* ---------------- tuning panel ---------------- */
const TUNE_FIELDS = [
  { group: 'EXPLOSION', key: 'blastRadius', label: 'BLAST RADIUS', min: 20, max: 160, step: 2, def: DEFAULTS.blastRadius, unit: 'px' },
  { group: 'EXPLOSION', key: 'falloff', label: 'SPLASH FALLOFF', type: 'check', desc: 'off = hard blast edge (classic)' },
  { group: 'EXPLOSION', key: 'falloffPct', label: 'FALLOFF EDGE %', min: 0, max: 100, step: 5, def: DEFAULTS.falloffPct, unit: '%' },
  { group: 'PHYSICS', key: 'gravity', label: 'GRAVITY', min: 200, max: 2000, step: 25, def: DEFAULTS.gravity, unit: 'px/s²' },
  { group: 'WIND', key: 'windMin', label: 'WIND MIN', min: 0, max: 200, step: 5, def: DEFAULTS.windMin, unit: '' },
  { group: 'WIND', key: 'windMax', label: 'WIND MAX', min: 0, max: 300, step: 5, def: DEFAULTS.windMax, unit: '' },
  { group: 'WIND', key: 'windShifts', label: 'WIND SHIFTS / TURN', type: 'check', desc: 'off = wind constant all match' },
  { group: 'CITY', key: 'buildingCount', label: 'BUILDING COUNT', min: 4, max: 20, step: 1, def: DEFAULTS.buildingCount, unit: '' },
  { group: 'CITY', key: 'bldgMinH', label: 'B. HEIGHT MIN', min: 40, max: 400, step: 10, def: DEFAULTS.bldgMinH, unit: 'px' },
  { group: 'CITY', key: 'bldgMaxH', label: 'B. HEIGHT MAX', min: 80, max: 560, step: 10, def: DEFAULTS.bldgMaxH, unit: 'px' },
  { group: 'LAUNCH', key: 'powerMin', label: 'POWER MIN', min: 1, max: 100, step: 1, def: DEFAULTS.powerMin, unit: '' },
  { group: 'LAUNCH', key: 'powerMax', label: 'POWER MAX', min: 10, max: 100, step: 1, def: DEFAULTS.powerMax, unit: '' }
];

function buildTuneUI(){
  const body = el('tuneBody');
  body.innerHTML = '';
  let lastGroup = '';
  for (const f of TUNE_FIELDS){
    if (f.group !== lastGroup){
      lastGroup = f.group;
      const h = document.createElement('h3');
      h.textContent = f.group;
      const g = document.createElement('div');
      g.className = 'tgroup';
      g.appendChild(h);
      body.appendChild(g);
    }
    const row = document.createElement('div');
    row.className = 'trow';
    const name = document.createElement('span');
    name.className = 'tname';
    name.textContent = f.label;
    row.appendChild(name);
    if (f.type === 'check'){
      const chk = document.createElement('input');
      chk.type = 'checkbox';
      chk.className = 'chk';
      chk.checked = !!cfg[f.key];
      const note = document.createElement('span');
      note.className = 'tnote';
      note.style.flex = '1';
      note.textContent = cfg[f.key] ? 'ON' : 'OFF';
      chk.addEventListener('change', ()=>{
        cfg[f.key] = chk.checked;
        note.textContent = chk.checked ? 'ON' : 'OFF';
        note.classList.toggle('modified', cfg[f.key] !== DEFAULTS[f.key]);
        if (f.key === 'windShifts' && !chk.checked){
          // lock current wind for the rest of the match
          state.windMag = Math.abs(state.wind);
          toast('WIND LOCKED FOR MATCH');
        }
        markModified(f, note);
      });
      row.appendChild(chk);
      row.appendChild(note);
    } else {
      const rng = document.createElement('input');
      rng.type = 'range';
      rng.min = f.min; rng.max = f.max; rng.step = f.step;
      rng.value = cfg[f.key];
      const num = document.createElement('input');
      num.type = 'number';
      num.min = f.min; num.max = f.max; num.step = f.step;
      num.value = cfg[f.key];
      const unit = document.createElement('span');
      unit.className = 'tnote';
      unit.textContent = f.unit || '';
      const refresh = (fromNum)=>{
        let v = parseFloat(fromNum !== undefined ? fromNum : rng.value);
        if (isNaN(v)) v = cfg[f.key];
        v = clamp(v, f.min, f.max);
        v = Math.round(v / f.step) * f.step;
        v = Math.round(v * 100) / 100;
        cfg[f.key] = v;
        if (fromNum !== undefined) rng.value = v; else num.value = v;
        markModified(f, num);
        onCfgChanged(f.key, v);
      };
      rng.addEventListener('input', ()=> refresh());
      num.addEventListener('change', ()=> refresh(num.value));
      num.addEventListener('input', ()=>{ if (num.value !== '' && !isNaN(parseFloat(num.value))) refresh(num.value); });
      if (cfg[f.key] !== f.def) num.classList.add('modified');
      row.appendChild(rng);
      row.appendChild(num);
      row.appendChild(unit);
    }
    body.lastElementChild.appendChild(row);
  }
}
function markModified(f, elNum){
  elNum.classList.toggle('modified', cfg[f.key] !== f.def);
}
function onCfgChanged(key, v){
  // immediate-effect clamps & feedback
  if (key === 'powerMin' && v > cfg.powerMax) cfg.powerMax = v;
  if (key === 'powerMax' && v < cfg.powerMin) cfg.powerMin = v;
  if (key === 'windMin' && v > cfg.windMax) cfg.windMax = v;
  if (key === 'windMax' && v < cfg.windMin) cfg.windMin = v;
  if (key === 'bldgMinH' && v > cfg.bldgMaxH) cfg.bldgMaxH = v;
  if (key === 'bldgMaxH' && v < cfg.bldgMinH) cfg.bldgMinH = v;
  for (let i = 0; i < 2; i++){
    state.power[i] = clamp(state.power[i], cfg.powerMin, cfg.powerMax);
  }
  toast('TUNED: ' + key.toUpperCase() + ' = ' + v + ' (next launch / next match)');
}

function openTune(open){
  el('tunePanel').classList.toggle('open', open);
}
function toggleTune(){
  openTune(!el('tunePanel').classList.contains('open'));
}

/* ---------------- boot ---------------- */
buildTuneUI();
startMatch();           // pre-generate a city behind the title screen
state.matchNum = 0;     // ...counter starts at the player's first real match
state.phase = 'title';  // ...but wait for the player
document.getElementById('titleScreen').classList.remove('hidden');
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"38d3c8ab-0d3a-439a-9daa-41b0e0e794ba","tokensIn":2723650,"tokensOut":58180,"tokensTotal":2781830,"cost":0,"turns":45,"toolCalls":47,"failedToolCalls":0,"timestamp":"2026-08-18T05:40:24.527Z"} -->