← 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 &middot; <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>