Gorillaz
solar mini4 · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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,566 bytes · SHA-256 8d7f00411d43
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gorillaz — Artillery Showdown</title>
<style>
:root {
--bg: #0d0d1a;
--panel: #16162a;
--panel2: #1f1f3a;
--text: #f0f0f5;
--text-dim: #8888aa;
--accent: #0ff;
--accent2: #f5e05a;
--red: #ff4d4d;
--blue: #4d4dff;
--green: #4dff88;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: #000;
font-family: "Courier New", monospace;
overflow: hidden;
user-select: none;
}
#app {
display: flex; justify-content: center; align-items: center;
width: 100%; height: 100%;
padding: 12px;
}
#game-container {
position: relative;
width: 960px; max-width: 100%;
aspect-ratio: 16/9;
background: #0d0d1a;
border: 4px solid #333;
border-radius: 6px;
box-shadow: 0 0 0 2px #000, 0 0 40px rgba(0,0,0,0.8);
overflow: hidden;
}
#canvas {
display: block;
width: 100%; height: 100%;
border-radius: 2px;
background: #1a1a3a;
}
/* ---- HUD ---- */
#hud {
position: absolute; inset: 0;
pointer-events: none;
}
.hud-top, .hud-bottom {
position: absolute;
left: 14px; right: 14px;
}
.hud-top { top: 10px; display: flex; justify-content: space-between; align-items: center; }
.wind-box {
display: flex; align-items: center; gap: 8px;
background: rgba(0,20,40,0.85);
border: 2px solid #3a6a8a;
border-radius: 6px;
padding: 6px 12px;
color: var(--text);
font-size: 15px;
letter-spacing: 1px;
}
.wind-arrow {
font-size: 22px; font-weight: bold;
color: var(--accent2);
transition: transform 0.3s;
}
.wind-box.dir-right .wind-arrow { transform: scaleX(1); }
.wind-box.dir-left .wind-arrow { transform: scaleX(-1); }
.wind-value { font-weight: bold; color: var(--accent2); }
.turn-box {
display: flex; align-items: center; gap: 8px;
background: rgba(0,20,40,0.85);
border: 2px solid #6a4a3a;
border-radius: 6px;
padding: 6px 14px;
color: var(--text);
font-size: 16px;
font-weight: bold;
}
.turn-box .turn-label { color: var(--text-dim); font-size: 12px; letter-spacing: 2px; }
.turn-box .turn-text { color: var(--accent2); }
.score-box {
display: flex; gap: 18px;
color: var(--text);
font-size: 16px;
font-weight: bold;
}
.score-item { display: flex; align-items: center; gap: 6px; }
.score-item .dot { width: 10px; height: 10px; border-radius: 50%; }
.p1-dot { background: var(--red); }
.p2-dot { background: var(--blue); }
.score-item .num { color: var(--accent2); }
.hud-bottom {
bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.aim-box {
display: flex; flex-direction: column; align-items: center;
background: rgba(0,20,40,0.85);
border: 2px solid #6a8a3a;
border-radius: 6px;
padding: 6px 14px;
color: var(--text);
font-size: 15px;
}
.aim-box .aim-label { color: var(--text-dim); font-size: 12px; letter-spacing: 2px; }
.aim-box .aim-value { color: var(--accent2); font-size: 20px; font-weight: bold; }
/* ---- Overlays ---- */
.overlay {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
pointer-events: auto;
opacity: 0; visibility: hidden;
transition: opacity 0.35s, visibility 0.35s;
}
.overlay.active { opacity: 1; visibility: visible; }
.overlay-bg {
position: absolute; inset: 0;
background: rgba(0,0,0,0.65);
backdrop-filter: blur(4px);
}
.panel {
position: relative;
background: linear-gradient(180deg, #1a1a2e, #12121e);
border: 3px solid #3a3a5a;
border-radius: 10px;
padding: 30px 40px;
text-align: center;
max-width: 560px;
width: 100%;
box-shadow: 0 0 60px rgba(0,0,0,0.6);
}
.panel h1 {
font-size: 52px;
font-weight: 900;
letter-spacing: 6px;
color: var(--text);
}
.panel h1 .z { color: var(--accent2); }
.panel .sub {
color: var(--text-dim);
font-size: 16px;
margin-top: 6px;
letter-spacing: 2px;
}
.panel .title-badge {
margin-top: 12px;
display: inline-block;
background: rgba(0,20,40,0.8);
border: 2px solid #3a6a8a;
border-radius: 6px;
padding: 4px 14px;
font-size: 13px;
color: var(--accent);
letter-spacing: 2px;
}
.btn {
margin-top: 18px;
padding: 12px 32px;
font-family: "Courier New", monospace;
font-size: 18px;
font-weight: bold;
letter-spacing: 3px;
border: 3px solid var(--accent);
border-radius: 6px;
background: linear-gradient(180deg, #1a3a5a, #102030);
color: var(--text);
cursor: pointer;
pointer-events: auto;
transition: transform 0.1s, background 0.2s;
}
.btn:hover { background: linear-gradient(180deg, #2a4a6a, #1a2a3a); }
.btn:active { transform: scale(0.97); }
.btn.red { border-color: var(--red); background: linear-gradient(180deg, #3a1a1a, #1a0a0a); }
.btn.red:hover { background: linear-gradient(180deg, #4a1a1a, #2a0a0a); }
.btn.blue { border-color: var(--blue); background: linear-gradient(180deg, #1a1a3a, #0a0a20); }
.btn.blue:hover { background: linear-gradient(180deg, #2a2a4a, #1a1a30); }
.btn.green { border-color: var(--green); background: linear-gradient(180deg, #1a2a1a, #0a100a); }
.btn.green:hover { background: linear-gradient(180deg, #2a3a2a, #1a201a); }
.round-end .winner { font-size: 40px; font-weight: bold; color: var(--accent2); }
.round-end .round-label { font-size: 14px; color: var(--text-dim); letter-spacing: 3px; }
.round-end .match-over .winner { font-size: 48px; }
.round-end .match-over .round-label { font-size: 16px; }
.round-end .sub { font-size: 18px; }
.round-end .btn { margin-top: 24px; }
/* ---- Controls panel ---- */
#controlsPanel {
position: absolute; bottom: 12px; left: 12px; right: 12px;
pointer-events: auto;
display: none;
}
#controlsPanel.active { display: block; }
.controls-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 12px;
}
.player-selector { display: flex; gap: 8px; }
.player-btn {
padding: 6px 16px; font-family: "Courier New", monospace;
font-size: 14px; font-weight: bold; letter-spacing: 2px;
border: 2px solid #555; border-radius: 6px; background: #1a1a2e;
color: var(--text-dim); cursor: pointer;
}
.player-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(0,20,40,0.8); }
.ctrl-group {
display: flex; align-items: center; gap: 12px; margin-bottom: 10px;
}
.ctrl-group label { color: var(--text-dim); font-size: 13px; letter-spacing: 1px; }
.ctrl-group .value {
font-family: "Courier New", monospace; font-size: 22px; font-weight: bold;
color: var(--accent2); min-width: 60px; text-align: center;
}
.ctrl-group .unit { font-size: 12px; color: var(--text-dim); }
.ctrl-group .btn-sm {
width: 44px; height: 44px; font-size: 20px; font-weight: bold;
border: 2px solid #555; border-radius: 6px; background: #1a1a2e; color: var(--text);
cursor: pointer; font-family: "Courier New", monospace;
}
.ctrl-group .btn-sm:hover { border-color: var(--accent); }
#fireBtn {
padding: 14px 40px; font-size: 20px; font-weight: bold; letter-spacing: 4px;
border: 3px solid var(--accent2); border-radius: 8px; background: linear-gradient(180deg, #3a2a0a, #2a1a00);
color: var(--accent2); cursor: pointer; font-family: "Courier New", monospace;
margin-top: 6px;
}
#fireBtn:hover { background: linear-gradient(180deg, #4a3a1a, #3a2a0a); }
#fireBtn:active { transform: scale(0.97); }
.switch-player {
margin-top: 10px; padding: 6px 14px; font-size: 12px; font-family: "Courier New", monospace;
border: 2px dashed #555; border-radius: 6px; color: var(--text-dim);
cursor: pointer; background: transparent;
}
.switch-player:hover { border-color: var(--accent); color: var(--accent); }
/* ---- Settings modal ---- */
.modal {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
pointer-events: auto;
z-index: 10;
}
.modal.active { display: flex; }
.modal-bg {
position: absolute; inset: 0;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(6px);
cursor: pointer;
}
.modal-panel {
position: relative;
background: linear-gradient(180deg, #1c1c30, #141424);
border: 3px solid #4a4a6a;
border-radius: 10px;
padding: 24px 28px 20px;
width: 100%; max-width: 640px;
max-height: 90vh; overflow-y: auto;
box-shadow: 0 0 60px rgba(0,0,0,0.7);
}
.modal-panel h2 {
font-size: 22px; font-weight: 900; letter-spacing: 2px;
color: var(--text); margin-bottom: 4px;
}
.modal-panel .sub { color: var(--text-dim); font-size: 13px; letter-spacing: 1px; margin-bottom: 14px; }
.live-badge {
display: inline-block; background: #2a4a1a; border: 2px solid #4a8a2a;
color: #8fca4a; border-radius: 6px; padding: 4px 12px;
font-size: 12px; font-weight: bold; letter-spacing: 2px;
margin-bottom: 14px; text-align: center;
}
.setting {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 10px 0; border-bottom: 1px solid #2a2a40;
}
.setting:last-child { border-bottom: none; }
.setting .name { color: var(--text); font-size: 14px; }
.setting .name .desc { color: var(--text-dim); font-size: 12px; }
.setting .value-row { display: flex; align-items: center; gap: 8px; }
.setting .value {
font-family: "Courier New", monospace; font-size: 18px; font-weight: bold;
color: var(--accent2); min-width: 70px; text-align: right;
}
.setting .value .unit { font-size: 12px; color: var(--text-dim); font-weight: normal; }
.setting .spinner {
display: flex; gap: 4px;
}
.setting .spinner button {
width: 32px; height: 32px; font-size: 18px; font-weight: bold;
border: 2px solid #555; border-radius: 6px; background: #1a1a2e;
color: var(--text); cursor: pointer; font-family: "Courier New", monospace;
}
.setting .spinner button:hover { border-color: var(--accent); }
.setting .input {
font-family: "Courier New", monospace; font-size: 16px; font-weight: bold;
color: var(--text); border: 2px solid #4a4a6a; border-radius: 6px;
padding: 4px 8px; width: 90px; background: #0d0d1a;
}
.setting .input:focus { outline: none; border-color: var(--accent); }
.setting .toggle-row { display: flex; align-items: center; gap: 8px; }
.setting .toggle {
display: flex; align-items: center; gap: 6px;
color: var(--text); font-size: 13px; cursor: pointer;
user-select: none;
}
.setting .toggle .dot {
width: 18px; height: 18px; border-radius: 50%; border: 2px solid #555;
background: #1a1a2e; position: relative;
}
.setting .toggle.on .dot { border-color: var(--green); background: #1a3a1a; }
.setting .toggle.off .dot { border-color: var(--red); background: #3a1a1a; }
.setting .sel {
font-family: "Courier New", monospace; font-size: 16px; font-weight: bold;
color: var(--text); border: 2px solid #4a4a6a; border-radius: 6px;
padding: 4px 8px; background: #0d0d1a;
}
.modal-close {
position: absolute; top: 12px; right: 14px;
width: 36px; height: 36px; font-size: 22px; font-weight: bold;
border: 2px solid #555; border-radius: 6px; background: #1a1a2e;
color: var(--text); cursor: pointer; font-family: "Courier New", monospace;
}
.modal-close:hover { border-color: var(--accent); }
.settings-list { margin-top: 16px; }
/* ---- Canvas decorations (wind particles) ---- */
.wind-canvas {
position: absolute; inset: 0; pointer-events: none;
overflow: hidden;
}
</style>
</head>
<body>
<div id="app">
<div id="game-container">
<canvas id="canvas" width="960" height="540"></canvas>
<div id="hud">
<div class="hud-top">
<div class="wind-box" id="windBox">
<span class="wind-label">WIND</span>
<span class="wind-arrow" id="windArrow">—</span>
<span class="wind-value" id="windValue">0</span>
<span class="wind-unit">px/s</span>
</div>
<div class="turn-box">
<span class="turn-label">TURN</span>
<span class="turn-text" id="turnText">Player 1</span>
</div>
<div class="score-box">
<span class="score-item"><span class="dot p1-dot"></span><span class="num" id="score1">0</span></span>
<span class="score-item"><span class="dot p2-dot"></span><span class="num" id="score2">0</span></span>
</div>
</div>
<div class="hud-bottom">
<div class="aim-box">
<span class="aim-label">ANGLE</span>
<span class="aim-value" id="angleValue">45°</span>
</div>
<div class="aim-box">
<span class="aim-label">POWER</span>
<span class="aim-value" id="velocityValue">50</span>
</div>
</div>
</div>
<!-- Title screen -->
<div class="overlay" id="titleScreen">
<div class="overlay-bg"></div>
<div class="panel">
<h1>GORILLA<span class="z">Z</span></h1>
<p class="sub">ARTILLERY SHOWDOWN</p>
<div class="title-badge">RETRO ARCADE · 2-PLAYER</div>
<button class="btn" id="newMatchBtn">NEW MATCH</button>
</div>
</div>
<!-- Round end screen -->
<div class="overlay round-end" id="roundEndScreen">
<div class="overlay-bg"></div>
<div class="panel">
<div class="winner" id="roundEndText">Player 1 Wins!</div>
<p class="round-label" id="roundEndSub">Round <span id="roundEndRound">1</span> of 3</p>
<p class="sub" id="roundEndScore">P1: 1 · P2: 0</p>
<button class="btn green" id="rematchBtn">REMATCH</button>
</div>
</div>
<!-- Controls panel -->
<div id="controlsPanel">
<div class="controls-header">
<div class="player-selector">
<button class="player-btn active" data-player="1" id="p1Selector">Player 1</button>
<button class="player-btn" data-player="2" id="p2Selector">Player 2</button>
</div>
<span class="switch-player" id="switchPlayerBtn">← Switch Player →</span>
</div>
<div class="ctrl-group">
<label>ANGLE</label>
<div class="spinner">
<button class="btn-sm" id="angleMinus">−</button>
<span class="value" id="angleValueCtrl">45</span>
<button class="btn-sm" id="anglePlus">+</button>
</div>
<span class="unit">°</span>
</div>
<div class="ctrl-group">
<label>POWER</label>
<div class="spinner">
<button class="btn-sm" id="velocityMinus">−</button>
<span class="value" id="velocityValueCtrl">50</span>
<button class="btn-sm" id="velocityPlus">+</button>
</div>
<span class="unit">/100</span>
</div>
<button id="fireBtn">FIRE</button>
</div>
<!-- Settings modal -->
<div class="modal" id="settingsModal">
<div class="modal-bg" id="settingsBg"></div>
<div class="modal-panel">
<button class="modal-close" id="settingsClose">✕</button>
<h2>⚙ LIVE TUNING</h2>
<p class="sub">Edit physics & game constants — changes apply on the next turn.</p>
<div class="live-badge" id="liveBadge">LIVE TUNING ACTIVE</div>
<div class="settings-list" id="settingsList"></div>
</div>
</div>
</div>
</div>
<script>
"use strict";
// ─────────────────────────────────────────────
// CONFIG (defaults — overridable via live tuning)
// ─────────────────────────────────────────────
const CFG = {
blastRadius: 60, // px
gravity: 400, // px/s^2
windVariance: 150, // max |wind| px/s
windShift: true, // wind re-randomizes each turn
buildingCount: 10, // 8-14
buildingHeightMin: 40,
buildingHeightMax: 300,
velocityMin: 1,
velocityMax: 100,
damageThreshold: 'inside', // 'inside' | 'falloff'
};
// ─────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────
const S = {
phase: 'title', // title | playing | exploding | roundEnd | settings
activePlayer: 1,
angle: 45,
velocity: 50,
wind: 0,
buildings: [],
gorillas: [], // 2 gorillas
projectile: null,
explosion: null,
score: [0, 0],
round: 0,
seed: 0,
rng: null,
settingsOpen: false,
settingsDirty: false,
windHistory: [],
};
// ─────────────────────────────────────────────
// SEEDED RNG (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;
};
}
// ─────────────────────────────────────────────
// DOM REFS
// ─────────────────────────────────────────────
const $ = id => document.getElementById(id);
const canvas = $('canvas');
const ctx = canvas.getContext('2d');
const W = 960, H = 540;
const GROUND_Y = 480;
const GORILLA_R = 22;
const PROJ_R = 7;
const GRAVITY = CFG.gravity;
let BLAST_RADIUS = CFG.blastRadius;
// ─────────────────────────────────────────────
// BUILDING PALETTE
// ─────────────────────────────────────────────
const BUILD_COLORS = [
'#e5e5e5','#d93636','#36d97d','#365ad9','#d9a336',
'#a336d9','#36d9a3','#d9368a','#e5a336','#365ad9'
];
// ─────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────
function rand(min, max) { return S.rng() * (max - min) + min; }
function randInt(min, max) { return Math.floor(rand(min, max + 1)); }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// circle vs axis-aligned rect
function circleRect(cx, cy, cr, rx, ry, rw, rh) {
const nx = clamp(cx, rx, rx + rw);
const ny = clamp(cy, ry, ry + rh);
const dx = cx - nx, dy = cy - ny;
return dx*dx + dy*dy < cr*cr;
}
function circleRectNormal(cx, cy, cr, rx, ry, rw, rh) {
// returns closest point on rect surface to circle center
const nx = clamp(cx, rx, rx + rw);
const ny = clamp(cy, ry, ry + rh);
return { x: nx, y: ny };
}
function circleCircle(cx1, cy1, r1, cx2, cy2, r2) {
const dx = cx1 - cx2, dy = cy1 - cy2;
return dx*dx + dy*dy < (r1 + r2)*(r1 + r2);
}
// ─────────────────────────────────────────────
// CITY GENERATION
// ─────────────────────────────────────────────
function generateCity(seed) {
const rng = mulberry32(seed);
const count = randInt(CFG.buildingCount, CFG.buildingCount);
const buildings = [];
let x = 60;
const maxX = W - 60;
for (let i = 0; i < count; i++) {
const w = rand(40, 110);
const h = rand(CFG.buildingHeightMin, CFG.buildingHeightMax);
const gap = rand(6, 24);
const endX = x + w + gap;
if (endX > maxX) { w = maxX - x; }
const color = BUILD_COLORS[i % BUILD_COLORS.length];
buildings.push({ x, y: 0, w, h, color });
x = endX + gap;
}
// ensure at least one building reaches near the right edge
if (buildings.length > 0 && buildings[buildings.length - 1].x + buildings[buildings.length - 1].w < maxX - 80) {
buildings[buildings.length - 1].w = maxX - buildings[buildings.length - 1].x;
}
return buildings;
}
// ─────────────────────────────────────────────
// GORILLA PLACEMENT
// ─────────────────────────────────────────────
function placeGorillas(buildings) {
const left = buildings.filter(b => b.x + b.w / 2 < W / 2 - 40);
const right = buildings.filter(b => b.x + b.w / 2 > W / 2 + 40);
const rng = S.rng;
let leftB = left[randInt(0, left.length - 1)];
let rightB = right[randInt(0, right.length - 1)];
// ensure not in blast range of each other
while (leftB && rightB && Math.abs(leftB.x + leftB.w/2 - (rightB.x + rightB.w/2)) < BLAST_RADIUS + 30) {
leftB = left[randInt(0, left.length - 1)];
rightB = right[randInt(0, right.length - 1)];
}
return [
{ x: leftB.x + leftB.w/2, y: GROUND_Y - leftB.h, color: '#ff4d4d', alive: true, side: 'left', sway: 0 },
{ x: rightB.x + rightB.w/2, y: GROUND_Y - rightB.h, color: '#4d4dff', alive: true, side: 'right', sway: 0 },
];
}
// ─────────────────────────────────────────────
// MATCH START
// ─────────────────────────────────────────────
function startMatch() {
S.round = 0;
S.score = [0, 0];
S.phase = 'title';
S.angle = 45;
S.velocity = 50;
S.wind = 0;
S.projectile = null;
S.explosion = null;
S.settingsDirty = false;
S.windHistory = [];
S.seed = Date.now() ^ (Math.random() * 0xffffffff);
S.rng = mulberry32(S.seed);
S.buildings = generateCity(S.seed);
S.gorillas = placeGorillas(S.buildings);
$('score1').textContent = '0';
$('score2').textContent = '0';
$('angleValue').textContent = '45°';
$('velocityValue').textContent = '50';
$('turnText').textContent = 'Player 1';
$('turnText').style.color = 'var(--accent)';
setWind(S.wind, false);
showOverlay('titleScreen');
updateControls();
}
// ─────────────────────────────────────────────
// WIND
// ─────────────────────────────────────────────
function setWind(speed, animate) {
S.wind = speed;
const arrow = $('windArrow');
const box = $('windBox');
box.classList.remove('dir-right', 'dir-left');
if (speed > 0.5) { arrow.textContent = '▶'; box.classList.add('dir-right'); }
else if (speed < -0.5) { arrow.textContent = '◀'; box.classList.add('dir-left'); }
else { arrow.textContent = '—'; }
$('windValue').textContent = Math.round(speed);
S.windHistory.push(speed);
if (S.windHistory.length > 8) S.windHistory.shift();
}
function nextWind() {
if (CFG.windShift) {
S.wind = rand(-CFG.windVariance, CFG.windVariance);
}
setWind(S.wind, true);
}
// ─────────────────────────────────────────────
// PROJECTILE
// ─────────────────────────────────────────────
function launchProjectile() {
const g = S.gorillas[S.activePlayer - 1];
const rad = S.angle * Math.PI / 180;
const speed = S.velocity * 6.5;
S.projectile = {
x: g.x, y: g.y,
vx: Math.cos(rad) * speed,
vy: -Math.sin(rad) * speed,
radius: PROJ_R,
trail: [],
life: 0,
active: true,
};
playSound('launch');
S.phase = 'playing';
}
function updateProjectile(dt) {
if (!S.projectile) return;
const p = S.projectile;
p.vx += S.wind * dt;
p.vy += GRAVITY * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life += dt;
p.trail.push({ x: p.x, y: p.y, life: 1 });
if (p.trail.length > 14) p.trail.shift();
if (p.y + p.radius > GROUND_Y) {
p.y = GROUND_Y - p.radius;
triggerExplosion(p.x, p.y, 'ground');
return;
}
for (const b of S.buildings) {
if (circleRect(p.x, p.y, p.radius, b.x, b.y, b.w, b.h)) {
const closest = circleRectNormal(p.x, p.y, p.radius, b.x, b.y, b.w, b.h);
p.x = closest.x; p.y = closest.y;
triggerExplosion(p.x, p.y, 'building');
return;
}
}
for (const g of S.gorillas) {
if (!g.alive) continue;
if (circleCircle(p.x, p.y, p.radius, g.x, g.y, GORILLA_R)) {
p.x = g.x; p.y = g.y;
triggerExplosion(p.x, p.y, 'gorilla', g.side);
return;
}
}
}
function triggerExplosion(x, y, source, gorillaSide) {
const ex = x, ey = y;
S.explosion = {
x: ex, y: ey,
radius: 0,
maxRadius: BLAST_RADIUS,
startTime: performance.now(),
particles: [],
scorch: { x: ex, y: ey, life: 0 },
source: source,
gorillaSide: gorillaSide,
active: true,
};
const count = 26;
for (let i = 0; i < count; i++) {
const a = rand(0, Math.PI * 2);
const sp = rand(60, 220);
S.explosion.particles.push({
x: ex, y: ey,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 60,
life: rand(0.4, 1.2),
size: rand(3, 7),
color: ['#ff6b35','#ffd06b','#ff2d2d','#fff','#ff8a3d'][randInt(0,4)],
});
}
const gorilla = S.gorillas.find(g => g.side === gorillaSide);
let damage = 0;
if (gorilla) {
if (CFG.damageThreshold === 'falloff') {
const d = Math.hypot(ex - gorilla.x, ey - gorilla.y);
damage = clamp(1 - d / BLAST_RADIUS, 0, 1);
} else {
const d = Math.hypot(ex - gorilla.x, ey - gorilla.y);
damage = d <= BLAST_RADIUS ? 1 : 0;
}
}
if (damage > 0) {
gorilla.alive = false;
playSound('explode');
showRoundEnd(gorillaSide === 'left' ? 2 : 1);
return;
}
playSound('impact');
S.explosion.scorch = { x: ex, y: ey, life: 0 };
S.projectile = null;
S.phase = 'exploding';
}
// ─────────────────────────────────────────────
// OVERLAY SHOW/HIDE
// ─────────────────────────────────────────────
function showOverlay(id) {
document.querySelectorAll('.overlay').forEach(o => o.classList.remove('active'));
$(id).classList.add('active');
}
// ─────────────────────────────────────────────
// ROUND END
// ─────────────────────────────────────────────
function showRoundEnd(winner) {
const p1Wins = S.score[0];
const p2Wins = S.score[1];
const totalRounds = 3;
const matchOver = (winner === 1 && p1Wins >= 2) || (winner === 2 && p2Wins >= 2);
$('roundEndText').textContent = winner === 1 ? 'Player 1 Wins!' : 'Player 2 Wins!';
$('roundEndSub').textContent = `Round ${S.round} of ${totalRounds}`;
$('roundEndScore').textContent = `P1: ${p1Wins} · P2: ${p2Wins}`;
$('roundEndRound').textContent = S.round;
$('roundEndScreen').classList.add('match-over');
$('roundEndScreen').classList.add('active');
}
// ─────────────────────────────────────────────
// CONTROLS
// ─────────────────────────────────────────────
function updateControls() {
const p1 = $('p1Selector');
const p2 = $('p2Selector');
if (S.activePlayer === 1) {
p1.classList.add('active');
p2.classList.remove('active');
$('switchPlayerBtn').textContent = '← Switch to Player 2 →';
} else {
p1.classList.remove('active');
p2.classList.add('active');
$('switchPlayerBtn').textContent = '← Switch to Player 1 →';
}
}
// ─────────────────────────────────────────────
// INPUT
// ─────────────────────────────────────────────
const KEY = { left: 37, right: 39, up: 38, down: 40, enter: 13, space: 32, m: 77, i: 73, k: 75, j: 74 };
const KeyState = {};
document.addEventListener('keydown', e => {
KeyState[e.key] = true;
if (e.key === 'Escape') { if (S.phase === 'settings') closeSettings(); }
if (e.key === 'm' || e.key === 'M') { openSettings(); }
if (S.phase === 'playing' && S.activePlayer === 1) {
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') S.angle = clamp(S.angle - 1, 0, 90);
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') S.angle = clamp(S.angle + 1, 0, 90);
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') S.velocity = clamp(S.velocity + 1, CFG.velocityMin, CFG.velocityMax);
if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') S.velocity = clamp(S.velocity - 1, CFG.velocityMin, CFG.velocityMax);
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fire(); }
}
if (S.phase === 'playing' && S.activePlayer === 2) {
if (e.key === 'j' || e.key === 'J') S.angle = clamp(S.angle - 1, 0, 90);
if (e.key === 'l' || e.key === 'L') S.angle = clamp(S.angle + 1, 0, 90);
if (e.key === 'i' || e.key === 'I') S.velocity = clamp(S.velocity + 1, CFG.velocityMin, CFG.velocityMax);
if (e.key === 'k' || e.key === 'K') S.velocity = clamp(S.velocity - 1, CFG.velocityMin, CFG.velocityMax);
if (e.key === 'm' || e.key === 'M') { e.preventDefault(); fire(); }
}
});
// ─────────────────────────────────────────────
// FIRE
// ─────────────────────────────────────────────
function fire() {
if (S.projectile || S.phase !== 'playing') return;
launchProjectile();
}
// ─────────────────────────────────────────────
// AUDIO (Web Audio API)
// ─────────────────────────────────────────────
const Audio = {
ctx: null,
init() {
if (Audio.ctx) return;
try { Audio.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
},
play(type) {
Audio.init();
if (!Audio.ctx) return;
const ctx = Audio.ctx;
const now = ctx.currentTime;
if (type === 'launch') {
// short noise whoosh + sine sweep
const bufSize = ctx.sampleRate * 0.08;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i/bufSize, 2);
const src = ctx.createBufferSource(); src.buffer = buf;
const filt = ctx.createBiquadFilter(); filt.type = 'highpass'; filt.frequency.value = 400;
const sw = ctx.createOscillator(); sw.type = 'sine'; sw.frequency.setValueAtTime(250, now);
sw.frequency.exponentialRampToValueAtTime(700, now + 0.08);
const g = ctx.createGain(); g.gain.setValueAtTime(0.18, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
src.connect(filt); filt.connect(g); g.connect(ctx.destination);
src.start(now); sw.start(now); sw.stop(now + 0.08);
} else if (type === 'impact') {
const bufSize = ctx.sampleRate * 0.12;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i/bufSize);
const src = ctx.createBufferSource(); src.buffer = buf;
const filt = ctx.createBiquadFilter(); filt.type = 'bandpass'; filt.frequency.value = 1200; filt.Q.value = 0.8;
const g = ctx.createGain(); g.gain.setValueAtTime(0.25, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
src.connect(filt); filt.connect(g); g.connect(ctx.destination);
src.start(now);
} else if (type === 'explode') {
const bufSize = ctx.sampleRate * 0.35;
const buf = ctx.createBuffer(1, bufSize, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < bufSize; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i/bufSize, 1.5);
const src = ctx.createBufferSource(); src.buffer = buf;
const filt = ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 800;
const g = ctx.createGain(); g.gain.setValueAtTime(0.4, now); g.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
src.connect(filt); filt.connect(g); g.connect(ctx.destination);
src.start(now);
} else if (type === 'victory') {
const notes = [523, 659, 784, 1047];
notes.forEach((f, i) => {
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f;
const g = ctx.createGain(); g.gain.setValueAtTime(0, now + i * 0.18);
g.gain.linearRampToValueAtTime(0.2, now + i * 0.18 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, now + i * 0.18 + 0.4);
o.connect(g); g.connect(ctx.destination);
o.start(now + i * 0.18); o.stop(now + i * 0.18 + 0.45);
});
}
}
};
// ─────────────────────────────────────────────
// RENDERING
// ─────────────────────────────────────────────
function drawBackground() {
const grad = ctx.createLinearGradient(0, 0, 0, GROUND_Y);
grad.addColorStop(0, '#1a2a4a');
grad.addColorStop(0.45, '#3a6a8a');
grad.addColorStop(0.7, '#7a9aaa');
grad.addColorStop(1, '#b8c8d8');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, GROUND_Y);
// sun
const sx = W - 120, sy = 70;
const sGrad = ctx.createRadialGradient(sx, sy, 8, sx, sy, 60);
sGrad.addColorStop(0, '#fff4c8');
sGrad.addColorStop(0.4, '#ffd700');
sGrad.addColorStop(1, '#ff8c00');
ctx.fillStyle = sGrad;
ctx.beginPath(); ctx.arc(sx, sy, 60, 0, Math.PI * 2); ctx.fill();
// sun rays
ctx.strokeStyle = 'rgba(255,200,100,0.35)';
ctx.lineWidth = 2;
for (let i = 0; i < 12; i++) {
const a = i / 12 * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(sx + Math.cos(a) * 40, sy + Math.sin(a) * 40);
ctx.lineTo(sx + Math.cos(a) * 70, sy + Math.sin(a) * 70);
ctx.stroke();
}
// clouds
ctx.fillStyle = 'rgba(255,255,255,0.85)';
for (const c of clouds) {
ctx.beginPath();
ctx.ellipse(c.x, c.y, c.w, c.h, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(c.x - c.w * 0.5, c.y + c.h * 0.2, c.w * 0.6, c.h * 0.7, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(c.x + c.w * 0.5, c.y + c.h * 0.25, c.w * 0.55, c.h * 0.65, 0, 0, Math.PI * 2);
ctx.fill();
}
}
let clouds = [];
function initClouds() {
clouds = [];
for (let i = 0; i < 5; i++) {
clouds.push({ x: rand(0, W), y: rand(20, 90), w: rand(80, 160), h: rand(20, 36) });
}
}
function drawGround() {
// dirt strip
ctx.fillStyle = '#5a4028';
ctx.fillRect(0, GROUND_Y, W, 16);
// ground line
ctx.fillStyle = '#3a2a1a';
ctx.fillRect(0, GROUND_Y, W, 3);
// grid lines (retro)
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
ctx.lineWidth = 1;
for (let x = 0; x < W; x += 40) {
ctx.beginPath(); ctx.moveTo(x, GROUND_Y); ctx.lineTo(x, GROUND_Y + 16); ctx.stroke();
}
}
function drawBuildings() {
for (const b of S.buildings) {
const x = b.x, y = GROUND_Y - b.h, w = b.w, h = b.h;
// main body
ctx.fillStyle = b.color;
ctx.fillRect(x, y, w, h);
// roof highlight
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(x, y, w, h * 0.18);
// dark top edge
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(x, y, w, 3);
// windows (retro)
ctx.fillStyle = 'rgba(255,220,120,0.55)';
for (let row = 1; row < 4; row++) {
for (let col = 1; col < 6; col++) {
const wx = x + 8 + col * (w - 16) / 5;
const wy = y + 8 + row * (h - 16) / 4;
ctx.fillRect(wx, wy, w * 0.18, h * 0.18);
}
}
}
}
function drawGorilla(g, anim) {
const t = anim || 0;
ctx.save();
ctx.translate(g.x, g.y);
// sway
const sway = g.side === 'left' ? Math.sin(t * 0.08) * 2 : Math.sin(t * 0.08) * -2;
ctx.rotate(sway * 0.02);
// body
const bw = 52, bh = 72;
ctx.fillStyle = g.color;
roundRect(ctx, -bw/2, -bh/2 + 8, bw, bh, 8);
// chest shading
ctx.fillStyle = 'rgba(255,255,255,0.15)';
roundRect(ctx, -bw/2 + 6, -bh/2 + 12, bw * 0.5, bh * 0.4, 5);
// arms
ctx.fillStyle = g.color;
roundRect(ctx, -bw/2 - 6, -bh/2 + 10, 18, 40, 8);
roundRect(ctx, bw/2 + 6, -bh/2 + 10, 18, 40, 8);
// legs
roundRect(ctx, -bw/2 + 10, bh/2 - 24, 18, 24, 8);
roundRect(ctx, bw/2 - 8, bh/2 - 24, 18, 24, 8);
// head
ctx.fillStyle = g.color;
ctx.beginPath(); ctx.arc(0, -bh/2 - 2, 22, 0, Math.PI * 2); ctx.fill();
// ears
ctx.fillStyle = g.color;
ctx.beginPath(); ctx.ellipse(-16, -bh/2 - 14, 8, 10, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(16, -bh/2 - 14, 8, 10, 0, 0, Math.PI * 2); ctx.fill();
// eyes
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(-7, -bh/2 - 8, 3.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(7, -bh/2 - 8, 3.5, 0, Math.PI * 2); ctx.fill();
// nose
ctx.fillStyle = '#555';
ctx.beginPath(); ctx.ellipse(0, -bh/2 - 2, 4, 3, 0, 0, Math.PI * 2); ctx.fill();
// mouth
ctx.strokeStyle = '#444'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, -bh/2 + 6, 8, 0.1, Math.PI - 0.1); ctx.stroke();
// win pose: arms up
if (g.winPose) {
ctx.fillStyle = g.color;
roundRect(ctx, -bw/2 - 6, -bh/2 - 20, 18, 40, 8);
roundRect(ctx, bw/2 + 6, -bh/2 - 20, 18, 40, 8);
}
ctx.restore();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
ctx.fill();
}
function drawProjectile(p, t) {
if (!p) return;
// trail
for (let i = 0; i < p.trail.length; i++) {
const tr = p.trail[i];
const alpha = (i / p.trail.length) * 0.6;
ctx.fillStyle = `rgba(245, 215, 66, ${alpha})`;
ctx.beginPath();
ctx.arc(tr.x, tr.y, 4 * (i / p.trail.length) + 2, 0, Math.PI * 2);
ctx.fill();
}
// banana
const rad = Math.atan2(p.vy, p.vx);
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(rad);
ctx.scale(1, 0.85);
ctx.beginPath();
ctx.moveTo(-4, -4);
ctx.quadraticCurveTo(8, 2, 16, 3);
ctx.quadraticCurveTo(22, 4, 20, 5);
ctx.lineTo(20, 6);
ctx.quadraticCurveTo(16, 5, 10, 3);
ctx.quadraticCurveTo(4, 1, -4, -4);
ctx.closePath();
ctx.fillStyle = '#f5d742';
ctx.fill();
ctx.strokeStyle = 'rgba(200,160,60,0.6)';
ctx.lineWidth = 1;
ctx.stroke();
// highlight
ctx.beginPath();
ctx.ellipse(4, -1, 5, 2, 0.2, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fill();
ctx.restore();
}
function drawExplosion() {
if (!S.explosion) return;
const e = S.explosion;
const now = performance.now();
const age = (now - e.startTime) / 1000;
// expanding ring
const rad = Math.min(e.radius + age * 90, e.maxRadius);
const alpha = clamp(1 - age / 0.6, 0, 1);
ctx.strokeStyle = `rgba(255, 107, 53, ${alpha * 0.6})`;
ctx.lineWidth = 3;
ctx.beginPath(); ctx.arc(e.x, e.y, rad, 0, Math.PI * 2); ctx.stroke();
// flash
if (age < 0.15) {
ctx.fillStyle = `rgba(255, 255, 200, ${alpha * 0.5})`;
ctx.beginPath(); ctx.arc(e.x, e.y, rad * 0.5, 0, Math.PI * 2); ctx.fill();
}
// particles
for (const p of e.particles) {
p.x += p.vx * 0.016;
p.y += p.vy * 0.016;
p.vy += 120 * 0.016;
p.life -= 0.016;
if (p.life > 0) {
const s = p.size * (p.life / 1.2);
ctx.fillStyle = `rgba(255, ${Math.floor(180 + 75 * p.life)}, 60, ${p.life * 0.9})`;
ctx.beginPath(); ctx.arc(p.x, p.y, s, 0, Math.PI * 2); ctx.fill();
}
}
// scorch
if (e.scorch.life < 1) {
e.scorch.life += 0.02;
const sl = clamp(e.scorch.life, 0, 1);
ctx.fillStyle = `rgba(20, 10, 5, ${sl * 0.85})`;
ctx.beginPath();
ctx.ellipse(e.scorch.x, e.scorch.y, 16, 10, 0, 0, Math.PI * 2);
ctx.fill();
}
}
// ─────────────────────────────────────────────
// GAME LOOP
// ─────────────────────────────────────────────
let lastTime = 0;
function gameLoop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.033);
lastTime = timestamp;
// phase logic
if (S.phase === 'playing' && S.projectile) {
updateProjectile(dt);
}
if (S.phase === 'exploding' && S.explosion) {
const now = performance.now();
const age = (now - S.explosion.startTime) / 1000;
S.explosion.radius = age * 90;
if (age > 0.9) {
S.phase = 'playing';
// pass turn
S.activePlayer = S.activePlayer === 1 ? 2 : 1;
nextWind();
$('turnText').textContent = `Player ${S.activePlayer}`;
$('turnText').style.color = S.activePlayer === 1 ? 'var(--accent)' : 'var(--accent)';
updateControls();
$('angleValue').textContent = `${Math.round(S.angle)}°`;
$('velocityValue').textContent = S.velocity;
}
}
if (S.phase === 'roundEnd') {
// keep showing
}
// render
ctx.save();
drawBackground();
drawGround();
drawBuildings();
// gorillas behind buildings? no — draw after buildings
for (const g of S.gorillas) {
drawGorilla(g, 0);
}
drawProjectile(S.projectile, 0);
drawExplosion();
ctx.restore();
// HUD updates
$('angleValue').textContent = `${Math.round(S.angle)}°`;
$('velocityValue').textContent = S.velocity;
requestAnimationFrame(gameLoop);
}
// ─────────────────────────────────────────────
// SETTINGS PANEL
// ─────────────────────────────────────────────
const SETTINGS_DEF = [
{ key: 'blastRadius', label: 'Blast Radius', type: 'number', min: 20, max: 200, step: 1, unit: 'px', defaultValue: 60 },
{ key: 'gravity', label: 'Gravity', type: 'number', min: 100, max: 800, step: 10, unit: 'px/s²', defaultValue: 400 },
{ key: 'windVariance', label: 'Wind Variance', type: 'range', min: 10, max: 400, step: 5, unit: 'px/s', defaultValue: 150 },
{ key: 'buildingCount', label: 'Building Count', type: 'number', min: 4, max: 20, step: 1, unit: 'buildings', defaultValue: 10 },
{ key: 'buildingHeightMin', label: 'Height Min', type: 'number', min: 10, max: 150, step: 5, unit: 'px', defaultValue: 40 },
{ key: 'buildingHeightMax', label: 'Height Max', type: 'number', min: 50, max: 500, step: 5, unit: 'px', defaultValue: 300 },
{ key: 'velocityMin', label: 'Power Min', type: 'number', min: 1, max: 200, step: 1, unit: '', defaultValue: 1 },
{ key: 'velocityMax', label: 'Power Max', type: 'number', min: 1, max: 200, step: 1, unit: '', defaultValue: 100 },
{ key: 'damageThreshold', label: 'Damage Threshold', type: 'select', options: ['inside', 'falloff'], defaultValue: 'inside' },
];
// track which keys changed this session (for rebuilding city)
const CHANGED_KEYS = {};
function openSettings() {
renderSettings();
$('settingsModal').classList.add('active');
S.settingsOpen = true;
S.phase = 'settings';
showOverlay('settingsBg');
$('settingsBg').addEventListener('click', closeSettings);
}
function closeSettings() {
$('settingsModal').classList.remove('active');
S.settingsOpen = false;
S.phase = 'playing';
$('settingsBg').removeEventListener('click', closeSettings);
}
function renderSettings() {
const list = $('settingsList');
list.innerHTML = '';
const container = document.createElement('div');
container.style.cssText = 'display:grid; grid-template-columns: 1fr 1fr; gap: 10px 24px;';
for (const def of SETTINGS_DEF) {
const row = document.createElement('div');
row.className = 'setting';
row.dataset.key = def.key;
const name = document.createElement('div');
name.className = 'name';
name.textContent = def.label;
const val = document.createElement('div');
val.className = 'value';
val.innerHTML = `<span class="unit">${def.unit}</span>`;
const valSpan = val.querySelector('.unit');
if (def.type === 'range') {
const wrap = document.createElement('div');
wrap.className = 'value-row';
const minSpan = document.createElement('span');
minSpan.className = 'value'; minSpan.textContent = def.min;
const mid = document.createElement('span'); mid.textContent = ' — ';
const maxSpan = document.createElement('span');
maxSpan.className = 'value'; maxSpan.textContent = def.max;
wrap.appendChild(minSpan); wrap.appendChild(mid); wrap.appendChild(maxSpan);
val.appendChild(wrap);
}
const sp = document.createElement('div');
sp.className = 'spinner';
const dec = document.createElement('button');
dec.textContent = '−';
const inc = document.createElement('button');
inc.textContent = '+';
sp.appendChild(dec); sp.appendChild(inc);
if (def.type === 'number') {
const input = document.createElement('input');
input.type = 'number';
input.className = 'input';
input.value = CFG[def.key];
input.addEventListener('change', () => {
const v = parseFloat(input.value);
if (!isNaN(v)) { CFG[def.key] = clamp(v, def.min, def.max); updateFromSettings(); };
});
sp.appendChild(input);
} else if (def.type === 'select') {
const sel = document.createElement('select');
sel.className = 'sel';
for (const o of def.options) {
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
if (CFG[def.key] === o) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', () => {
CFG[def.key] = sel.value;
updateFromSettings();
});
sp.appendChild(sel);
} else {
dec.addEventListener('click', () => { CFG[def.key] = clamp(CFG[def.key] - def.step, def.min, def.max); updateFromSettings(); });
inc.addEventListener('click', () => { CFG[def.key] = clamp(CFG[def.key] + def.step, def.min, def.max); updateFromSettings(); });
}
row.appendChild(name);
row.appendChild(val);
row.appendChild(sp);
container.appendChild(row);
}
list.appendChild(container);
$('liveBadge').style.display = 'block';
applySettingsToDOM();
}
function applySettingsToDOM() {
for (const def of SETTINGS_DEF) {
const row = document.querySelector(`.setting[data-key="${def.key}"]`);
if (!row) continue;
const val = row.querySelector('.value');
if (def.type === 'range') {
const spans = val.querySelectorAll('.value');
// spans: [min, max] inside wrap; the outer .value holds them
const wrap = val.querySelector('.value-row');
if (wrap) {
const children = wrap.querySelectorAll('.value');
if (children.length >= 2) {
children[0].textContent = CFG[def.key];
children[1].textContent = CFG[def.key];
}
}
} else if (def.type === 'number') {
const input = row.querySelector('.input');
if (input) input.value = CFG[def.key];
} else if (def.type === 'select') {
const sel = row.querySelector('.sel');
if (sel) sel.value = CFG[def.key];
}
}
}
function updateFromSettings() {
for (const def of SETTINGS_DEF) {
const row = document.querySelector(`.setting[data-key="${def.key}"]`);
if (!row) continue;
if (def.type === 'number') {
const input = row.querySelector('.input');
if (input) {
const v = parseFloat(input.value);
if (!isNaN(v)) CFG[def.key] = clamp(v, def.min, def.max);
}
} else if (def.type === 'select') {
const sel = row.querySelector('.sel');
if (sel) CFG[def.key] = sel.value;
} else if (def.type === 'range') {
const wrap = row.querySelector('.value-row');
if (wrap) {
const children = wrap.querySelectorAll('.value');
if (children.length >= 2) {
CFG[def.key] = parseFloat(children[0].textContent);
}
}
}
// mark changed
if (!CHANGED_KEYS[def.key]) CHANGED_KEYS[def.key] = false;
CHANGED_KEYS[def.key] = true;
}
applySettingsToDOM();
// update live constants
GRAVITY = CFG.gravity;
BLAST_RADIUS = CFG.blastRadius;
S.settingsDirty = true;
// re-render city if building count changed
if (CHANGED_KEYS['buildingCount']) {
S.buildings = generateCity(S.seed);
S.gorillas = placeGorillas(S.buildings);
}
}
function defChanged(key) {
return CHANGED_KEYS[key] === true;
}
// ─────────────────────────────────────────────
// GAME INIT
// ─────────────────────────────────────────────
function init() {
initClouds();
startMatch();
requestAnimationFrame(gameLoop);
}
// wire up buttons
$('newMatchBtn').addEventListener('click', () => {
startMatch();
showOverlay('titleScreen');
});
$('rematchBtn').addEventListener('click', () => {
S.seed = Date.now() ^ (Math.random() * 0xffffffff);
S.rng = mulberry32(S.seed);
S.buildings = generateCity(S.seed);
S.gorillas = placeGorillas(S.buildings);
$('turnText').textContent = `Player ${S.activePlayer}`;
$('turnText').style.color = 'var(--accent)';
updateControls();
$('angleValue').textContent = `${Math.round(S.angle)}°`;
$('velocityValue').textContent = S.velocity;
showOverlay('roundEndScreen');
setTimeout(() => {
S.phase = 'playing';
showOverlay('titleScreen');
}, 1200);
});
$('fireBtn').addEventListener('click', fire);
$('angleMinus').addEventListener('click', () => { if (S.phase === 'playing') S.angle = clamp(S.angle - 1, 0, 90); });
$('anglePlus').addEventListener('click', () => { if (S.phase === 'playing') S.angle = clamp(S.angle + 1, 0, 90); });
$('velocityMinus').addEventListener('click', () => { if (S.phase === 'playing') S.velocity = clamp(S.velocity - 1, CFG.velocityMin, CFG.velocityMax); });
$('velocityPlus').addEventListener('click', () => { if (S.phase === 'playing') S.velocity = clamp(S.velocity + 1, CFG.velocityMin, CFG.velocityMax); });
$('switchPlayerBtn').addEventListener('click', () => {
S.activePlayer = S.activePlayer === 1 ? 2 : 1;
updateControls();
$('turnText').textContent = `Player ${S.activePlayer}`;
$('turnText').style.color = 'var(--accent)';
});
$('p1Selector').addEventListener('click', () => { S.activePlayer = 1; updateControls(); });
$('p2Selector').addEventListener('click', () => { S.activePlayer = 2; updateControls(); });
$('settingsBtn')?.addEventListener('click', openSettings);
$('settingsClose').addEventListener('click', closeSettings);
init();
</script>
</body>
</html>