Gorillaz
Qwen 3.8 27B GGUF · full

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>° 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 ·
<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 ·
<span class="kbd">T</span> live tuning · <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 & generation constant is editable in-flight: blast radius, gravity, wind range, building count & 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"} -->