← back to Games Agentabrams
games/lights-out/index.html
472 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 Lights Out</title>
<style>
:root{
--bg:#070a12;
--bg2:#0d1220;
--panel:rgba(255,255,255,.045);
--stroke:rgba(140,180,255,.14);
--txt:#e8eefc;
--dim:#8a96b4;
--neon:#38f0d6;
--neon2:#12b8d8;
--lit-a:#3bffe0;
--lit-b:#1aa9ff;
--gap:clamp(6px,1.4vmin,14px);
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;margin:0}
body{
font-family:'Trebuchet MS',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
color:var(--txt);
background:
radial-gradient(1100px 700px at 15% -10%,rgba(30,200,220,.14),transparent 60%),
radial-gradient(900px 600px at 110% 120%,rgba(60,120,255,.13),transparent 55%),
linear-gradient(160deg,var(--bg),var(--bg2));
min-height:100%;
display:flex;
align-items:center;
justify-content:center;
padding:clamp(8px,2vmin,20px);
overflow:hidden;
user-select:none;
}
.wrap{
width:100%;
max-width:min(560px,94vmin);
display:flex;
flex-direction:column;
gap:clamp(10px,2vmin,16px);
}
header{text-align:center;line-height:1.05}
h1{
margin:0;
font-size:clamp(22px,5.4vmin,40px);
font-weight:800;
letter-spacing:.5px;
background:linear-gradient(90deg,var(--lit-a),var(--lit-b));
-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;
filter:drop-shadow(0 0 14px rgba(56,240,214,.35));
}
.sub{margin:3px 0 0;color:var(--dim);font-size:clamp(10px,2.4vmin,13px);letter-spacing:2px;text-transform:uppercase}
.stats{
display:flex;justify-content:center;gap:clamp(8px,2vmin,14px);flex-wrap:wrap;
}
.stat{
background:var(--panel);border:1px solid var(--stroke);border-radius:12px;
padding:clamp(6px,1.4vmin,10px) clamp(10px,2.4vmin,18px);
min-width:74px;text-align:center;backdrop-filter:blur(6px);
}
.stat .k{font-size:clamp(9px,2vmin,10px);color:var(--dim);letter-spacing:1.5px;text-transform:uppercase}
.stat .v{font-size:clamp(16px,4vmin,24px);font-weight:800;font-variant-numeric:tabular-nums;margin-top:2px}
.stat.best .v{color:var(--neon)}
.boardwrap{position:relative;width:100%;aspect-ratio:1/1;margin:0 auto}
#board{
position:absolute;inset:0;
display:grid;gap:var(--gap);
padding:var(--gap);
background:rgba(4,8,16,.5);
border:1px solid var(--stroke);
border-radius:clamp(14px,3vmin,22px);
box-shadow:inset 0 0 40px rgba(0,0,0,.5), 0 20px 60px rgba(0,0,0,.5);
}
.cell{
border:none;padding:0;margin:0;cursor:pointer;outline:none;
border-radius:clamp(8px,2.2vmin,16px);
position:relative;
background:linear-gradient(160deg,#12182a,#0a0f1c);
box-shadow:inset 0 1px 0 rgba(255,255,255,.05), inset 0 0 0 1px rgba(120,150,220,.08),
0 6px 14px rgba(0,0,0,.35);
transition:transform .16s cubic-bezier(.3,1.4,.5,1),
box-shadow .3s ease, background .3s ease, filter .3s ease;
color:transparent;
-webkit-appearance:none;appearance:none;
}
.cell::after{
content:"";position:absolute;inset:22%;border-radius:50%;
background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.15),transparent 60%);
opacity:.25;transition:opacity .3s ease;
}
.cell.on{
background:linear-gradient(160deg,var(--lit-a),var(--lit-b));
box-shadow:inset 0 0 18px rgba(255,255,255,.5),
0 0 22px rgba(56,240,214,.65),
0 0 46px rgba(26,169,255,.45),
0 6px 16px rgba(0,0,0,.3);
filter:saturate(1.15);
}
.cell.on::after{opacity:.85}
.cell:active{transform:scale(.9)}
.cell.pulse{animation:pulse .4s ease}
@keyframes pulse{0%{transform:scale(1)}45%{transform:scale(1.12)}100%{transform:scale(1)}}
@media (hover:hover){
.cell:hover{transform:translateY(-2px)}
.cell.on:hover{filter:saturate(1.3) brightness(1.06)}
}
.controls{display:flex;flex-wrap:wrap;gap:clamp(6px,1.6vmin,10px);justify-content:center;align-items:center}
button.btn,select.btn{
font-family:inherit;font-size:clamp(11px,2.6vmin,14px);font-weight:700;
color:var(--txt);background:var(--panel);border:1px solid var(--stroke);
border-radius:11px;padding:clamp(8px,1.8vmin,11px) clamp(12px,3vmin,18px);
cursor:pointer;letter-spacing:.5px;transition:.2s;backdrop-filter:blur(6px);
-webkit-appearance:none;appearance:none;
}
button.btn:hover,select.btn:hover{border-color:var(--neon);color:#fff;box-shadow:0 0 14px rgba(56,240,214,.25)}
button.btn:active{transform:translateY(1px)}
button.primary{
background:linear-gradient(160deg,rgba(56,240,214,.22),rgba(26,169,255,.18));
border-color:rgba(56,240,214,.4);color:#eafffb;
}
select.btn{padding-right:30px;background-image:linear-gradient(45deg,transparent 50%,var(--neon) 50%),linear-gradient(135deg,var(--neon) 50%,transparent 50%);background-position:calc(100% - 15px) center,calc(100% - 10px) center;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
select.btn option{background:#0d1220;color:var(--txt)}
.win-overlay{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
border-radius:clamp(14px,3vmin,22px);
background:radial-gradient(circle at 50% 40%,rgba(56,240,214,.14),rgba(4,8,16,.86));
backdrop-filter:blur(3px);opacity:0;pointer-events:none;transition:opacity .45s ease;
z-index:5;text-align:center;
}
.win-overlay.show{opacity:1;pointer-events:auto}
.win-card h2{
margin:0;font-size:clamp(28px,8vmin,54px);font-weight:800;
background:linear-gradient(90deg,var(--lit-a),#fff,var(--lit-b));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
filter:drop-shadow(0 0 20px rgba(56,240,214,.6));
animation:pop .5s cubic-bezier(.2,1.6,.4,1);
}
@keyframes pop{0%{transform:scale(.4);opacity:0}100%{transform:scale(1);opacity:1}}
.win-card p{margin:10px 0 16px;color:var(--txt);font-size:clamp(13px,3.2vmin,16px)}
.win-card .big{color:var(--neon);font-weight:800}
canvas.confetti{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:4}
footer{text-align:center;color:var(--dim);font-size:clamp(9px,2.2vmin,11px);letter-spacing:1px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>ABRAMS LIGHTS OUT</h1>
<p class="sub">Turn every light off</p>
</header>
<div class="stats">
<div class="stat"><div class="k">Moves</div><div class="v" id="moves">0</div></div>
<div class="stat"><div class="k">Time</div><div class="v" id="time">0:00</div></div>
<div class="stat best"><div class="k">Best</div><div class="v" id="best">—</div></div>
</div>
<div class="boardwrap">
<div id="board"></div>
<canvas class="confetti" id="confetti"></canvas>
<div class="win-overlay" id="overlay">
<div class="win-card">
<h2>SOLVED!</h2>
<p>Cleared in <span class="big" id="winMoves">0</span> moves · <span class="big" id="winTime">0:00</span></p>
<button class="btn primary" id="againBtn">New Puzzle</button>
</div>
</div>
</div>
<div class="controls">
<button class="btn primary" id="newBtn">New Puzzle</button>
<select class="btn" id="sizeSel" title="Grid size">
<option value="4">4 × 4</option>
<option value="5" selected>5 × 5</option>
<option value="6">6 × 6</option>
</select>
<button class="btn" id="muteBtn" title="Sound">🔊 Sound</button>
</div>
<footer>Click a light to toggle it and its neighbors · Pure vanilla JS, zero dependencies</footer>
</div>
<script>
(function(){
"use strict";
var boardEl = document.getElementById('board');
var movesEl = document.getElementById('moves');
var timeEl = document.getElementById('time');
var bestEl = document.getElementById('best');
var overlay = document.getElementById('overlay');
var winMovesEl = document.getElementById('winMoves');
var winTimeEl = document.getElementById('winTime');
var sizeSel = document.getElementById('sizeSel');
var muteBtn = document.getElementById('muteBtn');
var confettiCanvas = document.getElementById('confetti');
var N = parseInt(localStorage.getItem('lo_size') || '5', 10);
if ([4,5,6].indexOf(N) < 0) N = 5;
sizeSel.value = String(N);
var grid = []; // boolean[N*N]
var cells = []; // button elements
var moves = 0;
var solved = false;
var startTime = 0;
var elapsed = 0;
var timerId = null;
var muted = localStorage.getItem('lo_muted') === '1';
updateMuteBtn();
/* ---------- Audio (procedural, zero assets) ---------- */
var actx = null;
function audio(){
if (muted) return null;
if (!actx){
try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ return null; }
}
if (actx.state === 'suspended') actx.resume();
return actx;
}
function beep(freq, dur, type, gainVal){
var ac = audio(); if (!ac) return;
var o = ac.createOscillator();
var g = ac.createGain();
o.type = type || 'sine';
o.frequency.value = freq;
g.gain.value = 0;
o.connect(g); g.connect(ac.destination);
var t = ac.currentTime;
var peak = (gainVal == null ? 0.12 : gainVal);
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(peak, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.start(t);
o.stop(t + dur + 0.02);
}
function toggleSound(litCount){
// pitch nudges up as fewer lights remain
var base = 300 + (N*N - litCount) * 12;
beep(base, 0.12, 'triangle', 0.09);
}
function winSound(){
var notes = [523.25, 659.25, 783.99, 1046.5];
for (var i=0;i<notes.length;i++){
(function(f,i){ setTimeout(function(){ beep(f, 0.35, 'sine', 0.13); }, i*110); })(notes[i], i);
}
}
/* ---------- Board build ---------- */
function build(){
boardEl.style.gridTemplateColumns = 'repeat(' + N + ',1fr)';
boardEl.style.gridTemplateRows = 'repeat(' + N + ',1fr)';
boardEl.innerHTML = '';
cells = [];
for (var i=0;i<N*N;i++){
var b = document.createElement('button');
b.className = 'cell';
b.setAttribute('data-i', i);
b.setAttribute('aria-label', 'light ' + (i+1));
(function(idx){
b.addEventListener('click', function(){ press(idx, true); });
})(i);
boardEl.appendChild(b);
cells.push(b);
}
}
function render(){
for (var i=0;i<N*N;i++){
if (grid[i]) cells[i].classList.add('on');
else cells[i].classList.remove('on');
}
}
function neighbors(idx){
var r = Math.floor(idx / N), c = idx % N;
var out = [idx];
if (r > 0) out.push(idx - N);
if (r < N-1) out.push(idx + N);
if (c > 0) out.push(idx - 1);
if (c < N-1) out.push(idx + 1);
return out;
}
function applyPress(idx){
var ns = neighbors(idx);
for (var k=0;k<ns.length;k++) grid[ns[k]] = !grid[ns[k]];
}
function litCount(){
var n = 0;
for (var i=0;i<N*N;i++) if (grid[i]) n++;
return n;
}
function press(idx, human){
if (solved) return;
if (human && moves === 0 && !timerId) startTimer();
applyPress(idx);
// visual pulse on affected cells
var ns = neighbors(idx);
for (var k=0;k<ns.length;k++){
(function(el){
el.classList.remove('pulse');
void el.offsetWidth;
el.classList.add('pulse');
})(cells[ns[k]]);
}
render();
if (human){
moves++;
movesEl.textContent = moves;
var lc = litCount();
toggleSound(lc);
if (lc === 0) onWin();
}
}
/* ---------- Solvable board generation ---------- */
function newPuzzle(){
solved = false;
overlay.classList.remove('show');
stopConfetti();
moves = 0;
movesEl.textContent = '0';
resetTimer();
// start all-off, apply random valid presses -> guaranteed solvable
grid = new Array(N*N).fill(false);
var total = N*N;
var pressCount = Math.max(3, Math.floor(total * (0.35 + Math.random()*0.35)));
var used = {};
var applied = 0, guard = 0;
while (applied < pressCount && guard < 1000){
guard++;
var idx = Math.floor(Math.random()*total);
applyPress(idx);
applied++;
}
// ensure not already solved
if (litCount() === 0){ applyPress(Math.floor(Math.random()*total)); }
render();
loadBest();
}
/* ---------- Win ---------- */
function onWin(){
solved = true;
stopTimer();
winSound();
winMovesEl.textContent = moves;
winTimeEl.textContent = fmt(elapsed);
// best
var key = bestKey();
var prev = parseInt(localStorage.getItem(key) || '0', 10);
if (!prev || moves < prev){
localStorage.setItem(key, String(moves));
}
loadBest();
setTimeout(function(){ overlay.classList.add('show'); startConfetti(); }, 250);
}
function bestKey(){ return 'lo_best_' + N; }
function loadBest(){
var v = localStorage.getItem(bestKey());
bestEl.textContent = v ? v : '—';
}
/* ---------- Timer ---------- */
function fmt(sec){
var m = Math.floor(sec/60), s = sec%60;
return m + ':' + (s<10?'0':'') + s;
}
function startTimer(){
startTime = Date.now() - elapsed*1000;
if (timerId) clearInterval(timerId);
timerId = setInterval(function(){
elapsed = Math.floor((Date.now()-startTime)/1000);
timeEl.textContent = fmt(elapsed);
}, 250);
}
function stopTimer(){ if (timerId){ clearInterval(timerId); timerId = null; } }
function resetTimer(){
stopTimer(); elapsed = 0; timeEl.textContent = '0:00';
}
/* ---------- Confetti ---------- */
var cctx, particles = [], cfRAF = null;
function startConfetti(){
var wrap = confettiCanvas.parentElement;
confettiCanvas.width = wrap.clientWidth;
confettiCanvas.height = wrap.clientHeight;
cctx = confettiCanvas.getContext('2d');
particles = [];
var colors = ['#3bffe0','#1aa9ff','#ffffff','#12b8d8','#7cf9ff'];
for (var i=0;i<90;i++){
particles.push({
x: Math.random()*confettiCanvas.width,
y: Math.random()*-confettiCanvas.height,
r: 3 + Math.random()*5,
c: colors[Math.floor(Math.random()*colors.length)],
vx: (Math.random()-0.5)*2,
vy: 2 + Math.random()*3.5,
rot: Math.random()*Math.PI,
vr: (Math.random()-0.5)*0.3
});
}
if (cfRAF) cancelAnimationFrame(cfRAF);
var frames = 0;
(function loop(){
frames++;
cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height);
for (var i=0;i<particles.length;i++){
var p = particles[i];
p.x += p.vx; p.y += p.vy; p.rot += p.vr;
cctx.save();
cctx.translate(p.x,p.y); cctx.rotate(p.rot);
cctx.fillStyle = p.c;
cctx.globalAlpha = 0.9;
cctx.fillRect(-p.r/2,-p.r/2,p.r,p.r*1.6);
cctx.restore();
if (p.y > confettiCanvas.height + 20){ p.y = -20; p.x = Math.random()*confettiCanvas.width; }
}
if (frames < 240 && solved) cfRAF = requestAnimationFrame(loop);
else if (solved) { /* fade */ cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height); }
})();
}
function stopConfetti(){
if (cfRAF) cancelAnimationFrame(cfRAF);
if (cctx) cctx.clearRect(0,0,confettiCanvas.width,confettiCanvas.height);
}
/* ---------- Controls ---------- */
function updateMuteBtn(){
muteBtn.textContent = muted ? '🔇 Muted' : '🔊 Sound';
}
muteBtn.addEventListener('click', function(){
muted = !muted;
localStorage.setItem('lo_muted', muted ? '1':'0');
updateMuteBtn();
if (!muted) beep(500,0.1,'sine',0.08);
});
document.getElementById('newBtn').addEventListener('click', newPuzzle);
document.getElementById('againBtn').addEventListener('click', newPuzzle);
sizeSel.addEventListener('change', function(){
N = parseInt(sizeSel.value,10);
localStorage.setItem('lo_size', String(N));
build(); newPuzzle();
});
window.addEventListener('resize', function(){
if (solved && overlay.classList.contains('show')) startConfetti();
});
/* ---------- Init ---------- */
build();
newPuzzle();
})();
</script>
</body>
</html>