← back to Games Agentabrams
games/connect-four/index.html
721 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Abrams Connect 4</title>
<style>
:root{
--bg0:#070912;
--bg1:#0d1226;
--board:#0b1030;
--board-edge:#1b2a6b;
--cell:#050814;
--p1:#ff2d78; /* neon pink */
--p1b:#ff8fc0;
--p2:#22e0ff; /* neon cyan */
--p2b:#a9f4ff;
--ink:#eaf0ff;
--muted:#8b96c4;
--line:#2a3670;
--win:#ffe14d;
--accent:#8a6bff;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{margin:0;height:100%;}
body{
background:
radial-gradient(1200px 700px at 15% -10%, #16205a55, transparent 60%),
radial-gradient(1000px 700px at 110% 120%, #4a1e6b55, transparent 55%),
linear-gradient(160deg,var(--bg1),var(--bg0));
color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;flex-direction:column;align-items:center;
min-height:100%;overflow:hidden;
user-select:none;
}
.wrap{
width:100%;max-width:640px;height:100dvh;
display:flex;flex-direction:column;
padding:10px clamp(8px,3vw,18px);
gap:10px;
}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;}
.title{
font-weight:800;letter-spacing:.5px;font-size:clamp(18px,5vw,26px);
background:linear-gradient(90deg,var(--p1),var(--accent),var(--p2));
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 24px #8a6bff33;
display:flex;align-items:center;gap:8px;
}
.title .dot{width:12px;height:12px;border-radius:50%;background:var(--p1);box-shadow:0 0 12px var(--p1);}
.controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
select,button{
font:inherit;color:var(--ink);
background:linear-gradient(180deg,#141c40,#0c1330);
border:1px solid var(--line);border-radius:10px;
padding:8px 10px;cursor:pointer;transition:.15s;
font-size:13px;
}
button:hover,select:hover{border-color:var(--accent);box-shadow:0 0 0 1px #8a6bff44;}
button:active{transform:translateY(1px);}
.btn-primary{background:linear-gradient(180deg,#6d54ff,#4b32d6);border-color:#7a63ff;font-weight:700;}
.iconbtn{padding:8px 11px;}
.scorebar{
display:flex;gap:8px;align-items:stretch;justify-content:center;
}
.score{
flex:1;max-width:190px;
display:flex;align-items:center;gap:10px;
background:linear-gradient(180deg,#0f1638,#0a1029);
border:1px solid var(--line);border-radius:12px;
padding:8px 12px;position:relative;overflow:hidden;
}
.score.active{border-color:currentColor;box-shadow:0 0 18px -4px currentColor, inset 0 0 22px -12px currentColor;}
.score.p1{color:var(--p1);}
.score.p2{color:var(--p2);}
.score .chip{
width:26px;height:26px;border-radius:50%;flex:0 0 auto;
background:radial-gradient(circle at 35% 30%,#fff8,currentColor 60%);
box-shadow:0 0 12px currentColor;
}
.score .meta{display:flex;flex-direction:column;line-height:1.15;}
.score .name{font-size:12px;color:var(--muted);}
.score .val{font-size:20px;font-weight:800;color:var(--ink);}
.status{
text-align:center;font-size:14px;min-height:20px;color:var(--muted);
font-weight:600;letter-spacing:.3px;
}
.status b.p1{color:var(--p1);} .status b.p2{color:var(--p2);}
.boardwrap{flex:1;display:flex;align-items:center;justify-content:center;min-height:0;}
canvas{
display:block;width:100%;height:100%;
touch-action:none;border-radius:16px;
cursor:pointer;
}
footer{
text-align:center;font-size:11px;color:#5b6699;padding-bottom:4px;
}
.thinking{color:var(--accent);}
@media (max-width:420px){
.score .val{font-size:18px;}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="title"><span class="dot"></span>Abrams Connect 4</div>
<div class="controls">
<select id="mode" title="Game mode">
<option value="ai">vs Computer</option>
<option value="2p">2 Players</option>
</select>
<select id="diff" title="Difficulty">
<option value="1">Easy</option>
<option value="3">Medium</option>
<option value="5" selected>Hard</option>
<option value="7">Expert</option>
</select>
<button id="mute" class="iconbtn" title="Toggle sound">🔊</button>
<button id="newgame" class="btn-primary">New Game</button>
</div>
</header>
<div class="scorebar">
<div class="score p1 active" id="score1">
<span class="chip"></span>
<span class="meta"><span class="name" id="name1">You</span><span class="val" id="val1">0</span></span>
</div>
<div class="score p2" id="score2">
<span class="chip"></span>
<span class="meta"><span class="name" id="name2">Computer</span><span class="val" id="val2">0</span></span>
</div>
</div>
<div class="status" id="status">Your turn — drop a disc</div>
<div class="boardwrap">
<canvas id="board"></canvas>
</div>
<footer>Connect four in a row — horizontal, vertical, or diagonal · Pure vanilla JS, zero dependencies.</footer>
</div>
<script>
(() => {
"use strict";
// ---------- Constants ----------
const COLS = 7, ROWS = 6, NEED = 4;
const EMPTY = 0, P1 = 1, P2 = 2;
// ---------- DOM ----------
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const $ = id => document.getElementById(id);
const modeSel = $('mode'), diffSel = $('diff'), muteBtn = $('mute');
const statusEl = $('status');
const val1 = $('val1'), val2 = $('val2'), name1 = $('name1'), name2 = $('name2');
const score1El = $('score1'), score2El = $('score2');
// ---------- State ----------
let board = []; // board[r][c], r=0 is TOP
let current = P1;
let scores = { 1:0, 2:0 };
let gameOver = false;
let winLine = null; // array of [r,c]
let winPlayer = 0;
let aiThinking = false;
let hoverCol = -1;
let muted = false;
let animating = false;
// Animated / falling discs
const discs = []; // {r,c,player,y (px), targetY, vy, settled}
let dropAnim = null;
// Layout (recomputed on resize)
let L = { size: 0, pad: 0, cell: 0, radius: 0, boardX: 0, boardY: 0, boardW: 0, boardH: 0, top: 0 };
function newBoard() {
board = Array.from({length: ROWS}, () => new Array(COLS).fill(EMPTY));
}
// ---------- Audio (procedural, optional) ----------
let audioCtx = null;
function ac() {
if (muted) return null;
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch (e) { return null; }
}
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function tone(freq, dur, type = 'sine', gain = 0.12, slideTo = null) {
const a = ac(); if (!a) return;
const o = a.createOscillator(), g = a.createGain();
o.type = type; o.frequency.value = freq;
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, a.currentTime + dur);
g.gain.setValueAtTime(0, a.currentTime);
g.gain.linearRampToValueAtTime(gain, a.currentTime + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + dur);
o.connect(g); g.connect(a.destination);
o.start(); o.stop(a.currentTime + dur + 0.02);
}
const sfx = {
drop(){ tone(220, 0.14, 'triangle', 0.13, 140); },
land(){ tone(120, 0.10, 'sine', 0.16); tone(180, 0.06, 'square', 0.05); },
hover(){ tone(660, 0.03, 'sine', 0.04); },
win(){ [523,659,784,1046].forEach((f,i)=>setTimeout(()=>tone(f,0.22,'triangle',0.14),i*110)); },
draw(){ tone(300,0.2,'sine',0.1,180); },
click(){ tone(440,0.04,'square',0.05); }
};
// ---------- Layout ----------
function resize() {
const wrap = canvas.parentElement;
const availW = wrap.clientWidth;
const availH = wrap.clientHeight;
// board aspect: COLS wide, ROWS+~1 (drop lane) tall
const laneRows = ROWS + 1;
const targetRatio = COLS / laneRows;
let w = availW, h = w / targetRatio;
if (h > availH) { h = availH; w = h * targetRatio; }
const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
L.cell = w / COLS;
L.top = L.cell; // drop lane height (one cell)
L.boardX = 0;
L.boardY = L.top;
L.boardW = w;
L.boardH = L.cell * ROWS;
L.radius = L.cell * 0.40;
L.w = w; L.h = h;
// sync any settled disc pixel positions
for (const d of discs) if (d.settled) d.y = cellCenterY(d.r);
draw();
}
function cellCenterX(c){ return L.cell * c + L.cell/2; }
function cellCenterY(r){ return L.top + L.cell * r + L.cell/2; }
// ---------- Drawing ----------
function roundRect(x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
function discColors(p){
return p === P1
? { a:'#ff2d78', b:'#ff8fc0', glow:'#ff2d78' }
: { a:'#22e0ff', b:'#a9f4ff', glow:'#22e0ff' };
}
function drawDisc(cx, cy, p, r, highlight){
const col = discColors(p);
ctx.save();
ctx.shadowColor = col.glow;
ctx.shadowBlur = highlight ? r*1.1 : r*0.55;
const g = ctx.createRadialGradient(cx - r*0.35, cy - r*0.4, r*0.1, cx, cy, r);
g.addColorStop(0, col.b);
g.addColorStop(0.55, col.a);
g.addColorStop(1, p===P1 ? '#a3134b' : '#0f7f96');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
// rim
ctx.lineWidth = Math.max(1, r*0.06);
ctx.strokeStyle = 'rgba(255,255,255,.22)';
ctx.beginPath(); ctx.arc(cx, cy, r*0.96, 0, Math.PI*2); ctx.stroke();
// gloss
ctx.fillStyle = 'rgba(255,255,255,.28)';
ctx.beginPath(); ctx.ellipse(cx - r*0.3, cy - r*0.35, r*0.34, r*0.2, -0.5, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
function draw(){
if (!L.w) return;
ctx.clearRect(0,0,L.w,L.h);
// hover preview in the lane
if (!gameOver && !aiThinking && hoverCol >= 0 && dropRow(hoverCol) >= 0) {
const cx = cellCenterX(hoverCol);
ctx.save();
ctx.globalAlpha = 0.5;
drawDisc(cx, L.top/2, current, L.radius, false);
ctx.restore();
// column guide
ctx.save();
ctx.globalAlpha = 0.10;
ctx.fillStyle = discColors(current).a;
ctx.fillRect(L.cell*hoverCol, L.boardY, L.cell, L.boardH);
ctx.restore();
}
// board panel
ctx.save();
ctx.shadowColor = '#1b2a6b';
ctx.shadowBlur = 30;
const bg = ctx.createLinearGradient(0, L.boardY, 0, L.boardY+L.boardH);
bg.addColorStop(0, '#131a44');
bg.addColorStop(1, '#0a1030');
ctx.fillStyle = bg;
roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
ctx.fill();
ctx.restore();
// board edge stroke
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(120,150,255,.25)';
roundRect(L.boardX+2, L.boardY, L.boardW-4, L.boardH, L.cell*0.28);
ctx.stroke();
// holes + settled discs
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
const cx = cellCenterX(c), cy = cellCenterY(r);
// hole
ctx.save();
ctx.fillStyle = '#050814';
ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.fill();
ctx.lineWidth = Math.max(1, L.radius*0.05);
ctx.strokeStyle = 'rgba(0,0,0,.6)';
ctx.beginPath(); ctx.arc(cx, cy, L.radius, 0, Math.PI*2); ctx.stroke();
ctx.restore();
}
}
// discs (settled + falling)
for (const d of discs) {
const cx = cellCenterX(d.c);
const hi = winLine && winLine.some(p => p[0]===d.r && p[1]===d.c);
drawDisc(cx, d.y, d.player, L.radius, hi);
}
// win line
if (winLine && winLine.length) {
const a = winLine[0], b = winLine[winLine.length-1];
ctx.save();
ctx.strokeStyle = 'rgba(255,225,77,.9)';
ctx.lineWidth = L.radius*0.22;
ctx.lineCap = 'round';
ctx.shadowColor = '#ffe14d';
ctx.shadowBlur = 24;
ctx.beginPath();
ctx.moveTo(cellCenterX(a[1]), cellCenterY(a[0]));
ctx.lineTo(cellCenterX(b[1]), cellCenterY(b[0]));
ctx.stroke();
ctx.restore();
}
}
// ---------- Game logic ----------
function dropRow(c){
for (let r=ROWS-1;r>=0;r--) if (board[r][c]===EMPTY) return r;
return -1;
}
function validCols(bd){
const out=[];
for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) out.push(c);
return out;
}
function boardFull(bd){
for (let c=0;c<COLS;c++) if (bd[0][c]===EMPTY) return false;
return true;
}
// returns winning line (array of [r,c]) for player p, or null
function findWin(bd, p){
const dirs = [[0,1],[1,0],[1,1],[1,-1]];
for (let r=0;r<ROWS;r++){
for (let c=0;c<COLS;c++){
if (bd[r][c]!==p) continue;
for (const [dr,dc] of dirs){
const line=[[r,c]];
let rr=r+dr, cc=c+dc;
while (rr>=0 && rr<ROWS && cc>=0 && cc<COLS && bd[rr][cc]===p){
line.push([rr,cc]);
if (line.length===NEED) return line;
rr+=dr; cc+=dc;
}
}
}
}
return null;
}
// ---------- Human move ----------
function attemptDrop(c){
if (gameOver || animating || aiThinking) return;
if (modeSel.value==='ai' && current===P2) return; // not human's turn
const r = dropRow(c);
if (r<0) return;
doDrop(c, current);
}
function doDrop(c, player){
const r = dropRow(c);
if (r<0) return;
board[r][c] = player;
animating = true;
sfx.drop();
const targetY = cellCenterY(r);
const disc = { r, c, player, y: L.top/2, vy: 0, settled:false };
discs.push(disc);
startFall(disc, targetY);
}
function startFall(disc, targetY){
const grav = L.cell * 0.06; // px per frame^2 (scaled by cell)
let last = performance.now();
let bounced = false;
function step(now){
const dt = Math.min(2, (now - last)/16.6667); last = now;
disc.vy += grav * dt;
disc.y += disc.vy * dt;
if (disc.y >= targetY){
disc.y = targetY;
if (!bounced && disc.vy > L.cell*0.25){
disc.vy = -disc.vy * 0.28; // small bounce
bounced = true;
sfx.land();
draw();
requestAnimationFrame(step);
return;
}
disc.settled = true;
animating = false;
if (!bounced) sfx.land();
draw();
afterMove(disc.r, disc.c, disc.player);
return;
}
draw();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function afterMove(r, c, player){
const win = findWin(board, player);
if (win){
winLine = win; winPlayer = player; gameOver = true;
scores[player]++;
updateScores();
sfx.win();
setStatus(playerName(player, true) + ' wins the round! 🎉');
flashWin();
draw();
return;
}
if (boardFull(board)){
gameOver = true;
sfx.draw();
setStatus("It's a draw — board full.");
draw();
return;
}
current = player===P1 ? P2 : P1;
updateTurnUI();
if (modeSel.value==='ai' && current===P2 && !gameOver){
queueAI();
}
}
let winFlashTimer = null;
function flashWin(){
let n=0;
clearInterval(winFlashTimer);
winFlashTimer = setInterval(()=>{
n++;
// toggle handled implicitly by redraw jitter of glow via time — simple pulse:
draw();
if (n>8) clearInterval(winFlashTimer);
}, 140);
}
// ---------- AI: minimax + alpha-beta ----------
function queueAI(){
aiThinking = true;
setStatus('<span class="thinking">Computer is thinking…</span>');
draw();
// yield to let UI paint, keep responsive
setTimeout(runAI, 60);
}
function runAI(){
const depth = parseInt(diffSel.value, 10) || 5;
const move = bestMove(board, depth);
aiThinking = false;
if (move == null){ // no move (shouldn't happen)
updateTurnUI();
return;
}
doDrop(move, P2);
}
// Order columns center-first for better pruning
const COL_ORDER = (() => {
const mid=(COLS-1)/2;
return [...Array(COLS).keys()].sort((a,b)=>Math.abs(a-mid)-Math.abs(b-mid));
})();
function bestMove(bd, depth){
// immediate win / block shortcuts for snappiness at low depth
const valid = validColsOrdered(bd);
if (!valid.length) return null;
// win now?
for (const c of valid){ const b2=cloneDrop(bd,c,P2); if (findWin(b2,P2)) return c; }
// block opponent win?
for (const c of valid){ const b2=cloneDrop(bd,c,P1); if (findWin(b2,P1)) return c; }
let bestScore = -Infinity, bestCols = [valid[0]];
for (const c of valid){
const b2 = cloneDrop(bd, c, P2);
const s = minimax(b2, depth-1, -Infinity, Infinity, false);
if (s > bestScore){ bestScore = s; bestCols = [c]; }
else if (s === bestScore){ bestCols.push(c); }
}
// prefer central among ties
bestCols.sort((a,b)=>Math.abs(a-(COLS-1)/2)-Math.abs(b-(COLS-1)/2));
return bestCols[0];
}
function validColsOrdered(bd){
return COL_ORDER.filter(c => bd[0][c]===EMPTY);
}
function cloneDrop(bd, c, p){
const b2 = bd.map(row => row.slice());
for (let r=ROWS-1;r>=0;r--){ if (b2[r][c]===EMPTY){ b2[r][c]=p; break; } }
return b2;
}
function minimax(bd, depth, alpha, beta, maximizing){
if (findWin(bd, P2)) return 100000 + depth; // AI win, sooner better
if (findWin(bd, P1)) return -100000 - depth; // human win
if (depth===0 || boardFull(bd)) return evaluate(bd);
const cols = validColsOrdered(bd);
if (maximizing){
let value = -Infinity;
for (const c of cols){
const b2 = cloneDrop(bd, c, P2);
value = Math.max(value, minimax(b2, depth-1, alpha, beta, false));
alpha = Math.max(alpha, value);
if (alpha >= beta) break;
}
return value;
} else {
let value = Infinity;
for (const c of cols){
const b2 = cloneDrop(bd, c, P1);
value = Math.min(value, minimax(b2, depth-1, alpha, beta, true));
beta = Math.min(beta, value);
if (beta <= alpha) break;
}
return value;
}
}
// Heuristic evaluation from AI (P2) perspective
function evaluate(bd){
let score = 0;
// center column preference
const centerC = Math.floor(COLS/2);
let centerCount = 0;
for (let r=0;r<ROWS;r++) if (bd[r][centerC]===P2) centerCount++;
score += centerCount * 6;
const windows = [];
// horizontal
for (let r=0;r<ROWS;r++)
for (let c=0;c<=COLS-4;c++)
windows.push([bd[r][c],bd[r][c+1],bd[r][c+2],bd[r][c+3]]);
// vertical
for (let c=0;c<COLS;c++)
for (let r=0;r<=ROWS-4;r++)
windows.push([bd[r][c],bd[r+1][c],bd[r+2][c],bd[r+3][c]]);
// diag down-right
for (let r=0;r<=ROWS-4;r++)
for (let c=0;c<=COLS-4;c++)
windows.push([bd[r][c],bd[r+1][c+1],bd[r+2][c+2],bd[r+3][c+3]]);
// diag up-right
for (let r=3;r<ROWS;r++)
for (let c=0;c<=COLS-4;c++)
windows.push([bd[r][c],bd[r-1][c+1],bd[r-2][c+2],bd[r-3][c+3]]);
for (const w of windows) score += scoreWindow(w);
return score;
}
function scoreWindow(w){
let ai=0, hu=0, e=0;
for (const v of w){ if (v===P2) ai++; else if (v===P1) hu++; else e++; }
if (ai>0 && hu>0) return 0; // mixed, dead
let s=0;
if (ai===4) s+=1000;
else if (ai===3 && e===1) s+=12;
else if (ai===2 && e===2) s+=4;
if (hu===4) s-=1000;
else if (hu===3 && e===1) s-=16; // block threats a bit harder
else if (hu===2 && e===2) s-=4;
return s;
}
// ---------- UI helpers ----------
function playerName(p, cap){
if (modeSel.value==='ai') return p===P1 ? 'You' : 'Computer';
return p===P1 ? 'Player 1' : 'Player 2';
}
function setStatus(html){ statusEl.innerHTML = html; }
function updateScores(){ val1.textContent = scores[P1]; val2.textContent = scores[P2]; }
function updateNames(){
if (modeSel.value==='ai'){ name1.textContent='You'; name2.textContent='Computer'; }
else { name1.textContent='Player 1'; name2.textContent='Player 2'; }
}
function updateTurnUI(){
score1El.classList.toggle('active', current===P1);
score2El.classList.toggle('active', current===P2);
if (gameOver) return;
const cls = current===P1 ? 'p1' : 'p2';
const who = playerName(current);
if (modeSel.value==='ai' && current===P2){
setStatus('<span class="thinking">Computer is thinking…</span>');
} else {
setStatus(`<b class="${cls}">${who}</b> — drop a disc`);
}
}
// ---------- New game ----------
function newGame(){
clearInterval(winFlashTimer);
newBoard();
discs.length = 0;
current = P1;
gameOver = false; winLine = null; winPlayer = 0; aiThinking = false; animating = false;
hoverCol = -1;
updateNames();
updateScores();
updateTurnUI();
resize();
// if AI is P1? No — human/P1 always starts here.
}
// ---------- Input ----------
function colFromEvent(e){
const rect = canvas.getBoundingClientRect();
const x = (e.clientX ?? (e.touches && e.touches[0].clientX)) - rect.left;
const c = Math.floor(x / (rect.width / COLS));
return Math.max(0, Math.min(COLS-1, c));
}
canvas.addEventListener('mousemove', e => {
if (gameOver || aiThinking) { if (hoverCol!==-1){hoverCol=-1;draw();} return; }
const c = colFromEvent(e);
if (c !== hoverCol){ hoverCol = c; if(!muted && dropRow(c)>=0) sfx.hover(); draw(); }
});
canvas.addEventListener('mouseleave', () => { if (hoverCol!==-1){ hoverCol=-1; draw(); } });
canvas.addEventListener('click', e => { ac(); attemptDrop(colFromEvent(e)); });
// Touch
let touchCol = -1;
canvas.addEventListener('touchstart', e => {
ac();
e.preventDefault();
touchCol = colFromEvent(e);
hoverCol = touchCol;
draw();
}, {passive:false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const c = colFromEvent(e);
if (c!==hoverCol){ hoverCol=c; draw(); }
}, {passive:false});
canvas.addEventListener('touchend', e => {
e.preventDefault();
if (hoverCol>=0){ const c=hoverCol; hoverCol=-1; attemptDrop(c); draw(); }
}, {passive:false});
// Keyboard: 1-7 to drop, N new game
window.addEventListener('keydown', e => {
if (e.key>='1' && e.key<='7'){ ac(); attemptDrop(parseInt(e.key,10)-1); }
else if (e.key==='n' || e.key==='N'){ newGame(); }
});
// Controls
$('newgame').addEventListener('click', () => { ac(); sfx.click(); newGame(); });
modeSel.addEventListener('change', () => { sfx.click(); scores={1:0,2:0}; newGame(); });
diffSel.addEventListener('change', () => { sfx.click(); });
muteBtn.addEventListener('click', () => {
muted = !muted;
muteBtn.textContent = muted ? '🔇' : '🔊';
if (!muted) { ac(); sfx.click(); }
});
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 120));
// ---------- Boot ----------
newGame();
})();
</script>
</body>
</html>