← back to Games Agentabrams

games/simon/index.html

633 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 Simon</title>
<style>
  :root {
    --bg0: #05060a;
    --bg1: #0b0f1c;
    --ink: #e8f0ff;
    --muted: #7b88a8;
    --accent: #45e6ff;
    --green: #1fd67a;
    --red: #ff3b5c;
    --yellow: #ffd23b;
    --blue: #3b7bff;
  }

  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

  html, body {
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background:
      radial-gradient(circle at 50% 8%, #12203f 0%, var(--bg1) 45%, var(--bg0) 100%);
    color: var(--ink);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    user-select: none;
    -webkit-user-select: none;
    touch-action: manipulation;
  }

  #app {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: clamp(8px, 2.4vmin, 24px);
    gap: clamp(8px, 2vmin, 20px);
  }

  header {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    text-align: center;
  }

  h1 {
    margin: 0;
    font-size: clamp(20px, 5vmin, 40px);
    font-weight: 800;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    background: linear-gradient(90deg, var(--green), var(--yellow), var(--red), var(--blue), var(--accent));
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    filter: drop-shadow(0 0 12px rgba(69, 230, 255, 0.35));
  }

  .subtitle {
    font-size: clamp(10px, 1.8vmin, 13px);
    color: var(--muted);
    letter-spacing: 0.24em;
    text-transform: uppercase;
  }

  .stats {
    display: flex;
    gap: clamp(10px, 3vmin, 28px);
    align-items: stretch;
  }

  .stat {
    min-width: clamp(70px, 18vmin, 110px);
    padding: clamp(6px, 1.4vmin, 12px) clamp(10px, 2.4vmin, 18px);
    border-radius: 14px;
    background: rgba(20, 30, 56, 0.55);
    border: 1px solid rgba(90, 120, 190, 0.18);
    box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.35);
    text-align: center;
  }
  .stat .label {
    font-size: clamp(9px, 1.5vmin, 11px);
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--muted);
  }
  .stat .value {
    font-size: clamp(20px, 5vmin, 34px);
    font-weight: 800;
    line-height: 1.05;
    color: var(--accent);
    text-shadow: 0 0 14px rgba(69, 230, 255, 0.4);
  }
  .stat.best .value { color: var(--yellow); text-shadow: 0 0 14px rgba(255, 210, 59, 0.4); }

  /* Board */
  .board-wrap {
    position: relative;
    width: min(78vmin, 560px);
    height: min(78vmin, 560px);
    max-width: 90vw;
    max-height: 60vh;
  }

  .board {
    position: absolute;
    inset: 0;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
    gap: clamp(8px, 2.2vmin, 20px);
    border-radius: 50%;
    padding: clamp(8px, 2.2vmin, 20px);
    background:
      radial-gradient(circle at center, rgba(15, 22, 44, 0.9), rgba(5, 7, 14, 0.9));
    box-shadow:
      0 0 0 2px rgba(90, 120, 190, 0.15),
      0 0 60px rgba(0, 0, 0, 0.6),
      inset 0 0 40px rgba(0, 0, 0, 0.7);
  }

  .pad {
    position: relative;
    border: none;
    cursor: pointer;
    background: var(--c);
    filter: brightness(0.42) saturate(0.9);
    transition: filter 0.09s ease, box-shadow 0.09s ease, transform 0.06s ease;
    outline: none;
    box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.55);
  }
  .pad::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: inset 0 0 22px rgba(255, 255, 255, 0.08);
    pointer-events: none;
  }

  .pad.tl { border-top-left-radius: 100%; --c: var(--green); }
  .pad.tr { border-top-right-radius: 100%; --c: var(--red); }
  .pad.bl { border-bottom-left-radius: 100%; --c: var(--yellow); }
  .pad.br { border-bottom-right-radius: 100%; --c: var(--blue); }

  .pad.lit {
    filter: brightness(1.5) saturate(1.25);
    box-shadow:
      0 0 46px 8px var(--c),
      inset 0 0 40px rgba(255, 255, 255, 0.5);
    transform: scale(1.015);
  }

  .board.playable .pad:not(:disabled):active {
    filter: brightness(1.35) saturate(1.2);
  }

  /* Center hub */
  .hub {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 34%;
    height: 34%;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 30%, #16223f, #080b16 72%);
    border: 2px solid rgba(90, 120, 190, 0.25);
    box-shadow: 0 0 30px rgba(0, 0, 0, 0.7), inset 0 0 26px rgba(0, 0, 0, 0.7);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    z-index: 3;
    text-align: center;
    padding: 4px;
  }
  .hub .hub-title {
    font-size: clamp(10px, 2.1vmin, 15px);
    font-weight: 800;
    letter-spacing: 0.16em;
    color: var(--accent);
    text-shadow: 0 0 12px rgba(69, 230, 255, 0.5);
  }
  .hub .hub-msg {
    font-size: clamp(9px, 1.7vmin, 12px);
    color: var(--muted);
    letter-spacing: 0.06em;
    min-height: 1.1em;
  }

  /* Controls */
  .controls {
    display: flex;
    flex-wrap: wrap;
    gap: clamp(8px, 2vmin, 16px);
    align-items: center;
    justify-content: center;
  }

  button.ctrl {
    font-family: inherit;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    font-size: clamp(11px, 2vmin, 14px);
    color: var(--ink);
    background: linear-gradient(180deg, rgba(40, 58, 100, 0.75), rgba(20, 30, 56, 0.75));
    border: 1px solid rgba(90, 130, 210, 0.35);
    border-radius: 12px;
    padding: clamp(8px, 1.8vmin, 12px) clamp(16px, 4vmin, 26px);
    cursor: pointer;
    transition: transform 0.08s ease, box-shadow 0.15s ease, border-color 0.15s ease;
    box-shadow: 0 0 0 rgba(69, 230, 255, 0);
  }
  button.ctrl:hover { border-color: var(--accent); box-shadow: 0 0 18px rgba(69, 230, 255, 0.25); }
  button.ctrl:active { transform: translateY(1px) scale(0.98); }

  button.start {
    background: linear-gradient(180deg, var(--accent), #1b9fbf);
    color: #041018;
    border-color: rgba(69, 230, 255, 0.6);
    box-shadow: 0 0 22px rgba(69, 230, 255, 0.35);
  }
  button.start:hover { box-shadow: 0 0 30px rgba(69, 230, 255, 0.55); }

  .toggle {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    font-size: clamp(10px, 1.8vmin, 13px);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted);
    cursor: pointer;
    padding: clamp(7px, 1.6vmin, 11px) clamp(12px, 3vmin, 18px);
    border-radius: 12px;
    border: 1px solid rgba(90, 130, 210, 0.22);
    background: rgba(20, 30, 56, 0.4);
    transition: border-color 0.15s ease, color 0.15s ease;
  }
  .toggle.on { color: var(--ink); border-color: rgba(69, 230, 255, 0.5); }
  .toggle .dot {
    width: 12px; height: 12px; border-radius: 50%;
    background: rgba(120, 140, 180, 0.4);
    box-shadow: inset 0 0 4px rgba(0,0,0,0.6);
    transition: background 0.15s ease, box-shadow 0.15s ease;
  }
  .toggle.on .dot {
    background: var(--green);
    box-shadow: 0 0 10px var(--green);
  }
  .toggle.strict.on .dot { background: var(--red); box-shadow: 0 0 10px var(--red); }

  .board.flash-fail { animation: shake 0.4s ease; }
  @keyframes shake {
    0%,100% { transform: translateX(0); }
    20% { transform: translateX(-8px); }
    40% { transform: translateX(8px); }
    60% { transform: translateX(-5px); }
    80% { transform: translateX(5px); }
  }

  @media (max-height: 560px) {
    header .subtitle { display: none; }
    .board-wrap { max-height: 52vh; }
  }
</style>
</head>
<body>
<div id="app">
  <header>
    <h1>Abrams Simon</h1>
    <div class="subtitle">Watch &middot; Remember &middot; Repeat</div>
  </header>

  <div class="stats">
    <div class="stat"><div class="label">Round</div><div class="value" id="roundVal">0</div></div>
    <div class="stat best"><div class="label">Best</div><div class="value" id="bestVal">0</div></div>
  </div>

  <div class="board-wrap">
    <div class="board" id="board">
      <button class="pad tl" data-i="0" aria-label="green" disabled></button>
      <button class="pad tr" data-i="1" aria-label="red" disabled></button>
      <button class="pad bl" data-i="2" aria-label="yellow" disabled></button>
      <button class="pad br" data-i="3" aria-label="blue" disabled></button>
    </div>
    <div class="hub">
      <div class="hub-title" id="hubTitle">READY?</div>
      <div class="hub-msg" id="hubMsg">Press Start</div>
    </div>
  </div>

  <div class="controls">
    <button class="ctrl start" id="startBtn">Start</button>
    <div class="toggle strict" id="strictToggle" role="switch" aria-checked="false" tabindex="0">
      <span class="dot"></span><span>Strict</span>
    </div>
    <div class="toggle on" id="soundToggle" role="switch" aria-checked="true" tabindex="0">
      <span class="dot"></span><span>Sound</span>
    </div>
  </div>
</div>

<script>
(function () {
  "use strict";

  // ---- Elements ----
  const pads = Array.from(document.querySelectorAll(".pad"));
  const board = document.getElementById("board");
  const roundVal = document.getElementById("roundVal");
  const bestVal = document.getElementById("bestVal");
  const hubTitle = document.getElementById("hubTitle");
  const hubMsg = document.getElementById("hubMsg");
  const startBtn = document.getElementById("startBtn");
  const strictToggle = document.getElementById("strictToggle");
  const soundToggle = document.getElementById("soundToggle");

  // ---- State ----
  const BEST_KEY = "abrams-simon-best";
  const SOUND_KEY = "abrams-simon-sound";
  const STRICT_KEY = "abrams-simon-strict";

  let sequence = [];
  let userStep = 0;
  let acceptingInput = false;
  let playing = false;
  let busy = false; // during playback / transitions
  let best = 0;

  // pad tones (green, red, yellow, blue) — pleasant-ish intervals
  const TONES = [329.63, 261.63, 220.0, 164.81]; // E4, C4, A3, E3
  const FAIL_TONE = 110.0;

  // ---- Audio ----
  let audioCtx = null;
  let soundOn = true;

  function ensureAudio() {
    if (!audioCtx) {
      try {
        const AC = window.AudioContext || window.webkitAudioContext;
        audioCtx = new AC();
      } catch (e) {
        audioCtx = null;
      }
    }
    if (audioCtx && audioCtx.state === "suspended") {
      audioCtx.resume().catch(function () {});
    }
    return audioCtx;
  }

  function playTone(freq, durationMs, type) {
    if (!soundOn) return;
    const ctx = ensureAudio();
    if (!ctx) return;
    const now = ctx.currentTime;
    const dur = durationMs / 1000;

    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = type || "sine";
    osc.frequency.setValueAtTime(freq, now);

    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.28, now + 0.012);
    gain.gain.setValueAtTime(0.28, now + Math.max(0.012, dur - 0.06));
    gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);

    osc.connect(gain).connect(ctx.destination);
    osc.start(now);
    osc.stop(now + dur + 0.02);
  }

  function playFail() {
    if (!soundOn) return;
    const ctx = ensureAudio();
    if (!ctx) return;
    const now = ctx.currentTime;
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    osc.type = "sawtooth";
    osc.frequency.setValueAtTime(FAIL_TONE, now);
    osc.frequency.exponentialRampToValueAtTime(55, now + 0.7);
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.3, now + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.75);
    osc.connect(gain).connect(ctx.destination);
    osc.start(now);
    osc.stop(now + 0.8);
  }

  function winChime() {
    if (!soundOn) return;
    const ctx = ensureAudio();
    if (!ctx) return;
    [523.25, 659.25, 783.99].forEach(function (f, k) {
      setTimeout(function () { playTone(f, 160, "triangle"); }, k * 90);
    });
  }

  // ---- Timing (speeds up with level) ----
  function litDuration() {
    // starts ~560ms, floors ~200ms
    return Math.max(200, 560 - sequence.length * 22);
  }
  function gapDuration() {
    return Math.max(80, 200 - sequence.length * 8);
  }

  // ---- Visual pad flash ----
  function litPad(i, duration) {
    return new Promise(function (resolve) {
      const pad = pads[i];
      pad.classList.add("lit");
      playTone(TONES[i], duration, "sine");
      setTimeout(function () {
        pad.classList.remove("lit");
        resolve();
      }, duration);
    });
  }

  function sleep(ms) {
    return new Promise(function (r) { setTimeout(r, ms); });
  }

  // ---- Sequence playback ----
  async function playSequence() {
    busy = true;
    acceptingInput = false;
    setBoardPlayable(false);
    hubTitle.textContent = "WATCH";
    hubMsg.textContent = "Round " + sequence.length;
    await sleep(500);
    const dur = litDuration();
    const gap = gapDuration();
    for (let k = 0; k < sequence.length; k++) {
      await litPad(sequence[k], dur);
      await sleep(gap);
    }
    busy = false;
    acceptingInput = true;
    userStep = 0;
    setBoardPlayable(true);
    hubTitle.textContent = "YOUR TURN";
    hubMsg.textContent = "0 / " + sequence.length;
  }

  function setBoardPlayable(on) {
    pads.forEach(function (p) { p.disabled = !on; });
    board.classList.toggle("playable", on);
  }

  // ---- Round flow ----
  function nextRound() {
    sequence.push(Math.floor(Math.random() * 4));
    roundVal.textContent = sequence.length;
    playSequence();
  }

  function startGame() {
    ensureAudio();
    sequence = [];
    userStep = 0;
    playing = true;
    acceptingInput = false;
    roundVal.textContent = "0";
    startBtn.textContent = "Restart";
    board.classList.remove("flash-fail");
    hubTitle.textContent = "GO!";
    hubMsg.textContent = "";
    setTimeout(nextRound, 350);
  }

  async function handlePress(i) {
    if (!acceptingInput || busy || !playing) return;

    // flash the pressed pad
    const dur = 220;
    acceptingInput = false;
    await litPad(i, dur);

    if (i !== sequence[userStep]) {
      gameOver();
      return;
    }

    userStep++;
    hubMsg.textContent = userStep + " / " + sequence.length;

    if (userStep >= sequence.length) {
      // round cleared
      updateBest(sequence.length);
      winChime();
      hubTitle.textContent = "NICE";
      hubMsg.textContent = "Round " + sequence.length + " cleared";
      acceptingInput = false;
      setBoardPlayable(false);
      await sleep(650);
      nextRound();
    } else {
      acceptingInput = true;
    }
  }

  async function gameOver() {
    playing = false;
    acceptingInput = false;
    setBoardPlayable(false);
    playFail();
    board.classList.add("flash-fail");

    // flash all pads red-ish
    pads.forEach(function (p) { p.classList.add("lit"); });
    await sleep(320);
    pads.forEach(function (p) { p.classList.remove("lit"); });
    board.classList.remove("flash-fail");

    const reached = Math.max(0, sequence.length - 1);
    hubTitle.textContent = "GAME OVER";
    hubMsg.textContent = "Reached round " + reached;
    startBtn.textContent = "Start";

    if (strictOn) {
      // strict: sequence resets fully (already will on Start)
      sequence = [];
      roundVal.textContent = "0";
    }
  }

  function updateBest(level) {
    if (level > best) {
      best = level;
      bestVal.textContent = best;
      try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) {}
    }
  }

  // ---- Toggles ----
  let strictOn = false;

  function setSound(on) {
    soundOn = on;
    soundToggle.classList.toggle("on", on);
    soundToggle.setAttribute("aria-checked", on ? "true" : "false");
    try { localStorage.setItem(SOUND_KEY, on ? "1" : "0"); } catch (e) {}
    if (on) ensureAudio();
  }

  function setStrict(on) {
    strictOn = on;
    strictToggle.classList.toggle("on", on);
    strictToggle.setAttribute("aria-checked", on ? "true" : "false");
    try { localStorage.setItem(STRICT_KEY, on ? "1" : "0"); } catch (e) {}
  }

  // ---- Wire events ----
  pads.forEach(function (pad) {
    const i = parseInt(pad.getAttribute("data-i"), 10);
    // Pointer events cover mouse + touch; prevent double-fire.
    pad.addEventListener("pointerdown", function (ev) {
      ev.preventDefault();
      handlePress(i);
    });
    // keyboard access
    pad.addEventListener("keydown", function (ev) {
      if (ev.key === "Enter" || ev.key === " ") {
        ev.preventDefault();
        handlePress(i);
      }
    });
  });

  startBtn.addEventListener("click", function () {
    ensureAudio();
    startGame();
  });

  soundToggle.addEventListener("click", function () { setSound(!soundOn); });
  soundToggle.addEventListener("keydown", function (ev) {
    if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); setSound(!soundOn); }
  });

  strictToggle.addEventListener("click", function () { setStrict(!strictOn); });
  strictToggle.addEventListener("keydown", function (ev) {
    if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); setStrict(!strictOn); }
  });

  // number-key shortcuts 1-4
  window.addEventListener("keydown", function (ev) {
    if (ev.repeat) return;
    const map = { "1": 0, "2": 1, "3": 2, "4": 3 };
    if (ev.key in map) { handlePress(map[ev.key]); }
    else if (ev.key === "Enter" && !playing) { ensureAudio(); startGame(); }
  });

  // ---- Init from storage ----
  (function init() {
    try {
      const b = parseInt(localStorage.getItem(BEST_KEY) || "0", 10);
      if (!isNaN(b) && b > 0) { best = b; bestVal.textContent = b; }
    } catch (e) {}

    let s = true, st = false;
    try {
      const sv = localStorage.getItem(SOUND_KEY);
      if (sv !== null) s = sv === "1";
      const stv = localStorage.getItem(STRICT_KEY);
      if (stv !== null) st = stv === "1";
    } catch (e) {}
    setSound(s);
    setStrict(st);
  })();
})();
</script>
</body>
</html>