← back to Emoji Agentabrams
index.html
402 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Free-Roster — Emoji Memory Match</title>
<style>
:root{
--bg1:#1e1b4b; --bg2:#312e81; --accent:#a78bfa; --accent2:#f472b6;
--card-back:linear-gradient(135deg,#7c3aed,#db2777);
--card-face:#ffffff; --matched:#34d399; --miss:#f87171;
--text:#f8fafc; --panel:rgba(255,255,255,.07); --panel-bd:rgba(255,255,255,.1);
--seg-bg:rgba(255,255,255,.08); --seg-bd:rgba(255,255,255,.12); --seg-txt:#e2e8f0;
--foot:rgba(255,255,255,.4);
}
body.light{
--bg1:#ede9fe; --bg2:#c7d2fe;
--card-face:#ffffff;
--text:#1e1b4b; --panel:rgba(30,27,75,.06); --panel-bd:rgba(30,27,75,.12);
--seg-bg:rgba(30,27,75,.06); --seg-bd:rgba(30,27,75,.14); --seg-txt:#312e81;
--foot:rgba(30,27,75,.5);
}
*{box-sizing:border-box;margin:0;padding:0}
body{
min-height:100vh;
font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
background:linear-gradient(160deg,var(--bg1),var(--bg2));
color:var(--text);
display:flex;flex-direction:column;align-items:center;
padding:20px;gap:16px;
transition:background .3s,color .3s;
}
h1{
font-size:clamp(1.6rem,5vw,2.6rem);
font-weight:800;letter-spacing:.5px;
background:linear-gradient(90deg,var(--accent),var(--accent2));
-webkit-background-clip:text;background-clip:text;color:transparent;
text-align:center;
}
.controls{
display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;
}
.seg{
display:inline-flex;background:var(--seg-bg);
border-radius:999px;padding:4px;gap:4px;
border:1px solid var(--seg-bd);
}
.seg button{
border:none;background:transparent;color:var(--seg-txt);
padding:8px 16px;border-radius:999px;cursor:pointer;
font-size:.95rem;font-weight:600;transition:.2s;
}
.icon-btn{
border:1px solid var(--seg-bd);background:var(--seg-bg);color:var(--seg-txt);
cursor:pointer;font-size:1.1rem;font-weight:600;line-height:1;
width:42px;height:42px;border-radius:50%;transition:.2s;
display:inline-flex;align-items:center;justify-content:center;
}
.icon-btn:hover{transform:translateY(-2px)}
.icon-btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
.icon-btn.off{opacity:.45}
.seg button.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 4px 14px rgba(167,139,250,.4)}
.seg button:focus-visible,.btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
.btn{
border:none;cursor:pointer;font-weight:700;font-size:.95rem;
padding:10px 22px;border-radius:999px;color:#fff;
background:linear-gradient(135deg,var(--accent),var(--accent2));
box-shadow:0 4px 14px rgba(244,114,182,.35);transition:transform .15s,box-shadow .15s;
}
.btn:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(244,114,182,.5)}
.btn:active{transform:translateY(0)}
.stats{
display:flex;gap:22px;font-size:1.05rem;font-weight:600;flex-wrap:wrap;justify-content:center;
background:var(--panel);padding:12px 26px;border-radius:16px;
border:1px solid var(--panel-bd);
}
.stats span{color:var(--accent);font-variant-numeric:tabular-nums}
.stats .best{color:#fcd34d}
.board-wrap{position:relative;width:100%;max-width:560px}
#board{
display:grid;gap:10px;perspective:900px;width:100%;
}
#board.locked{pointer-events:none}
.card{
position:relative;aspect-ratio:1;cursor:pointer;
transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,.2,.2,1);
border-radius:14px;border:none;padding:0;background:transparent;
}
.card.flipped,.card.matched{transform:rotateY(180deg)}
.card.matched{cursor:default}
.card:focus-visible{outline:3px solid #fde68a;outline-offset:3px}
.card.miss{animation:shake .4s}
@keyframes shake{
0%,100%{transform:rotateY(180deg) translateX(0)}
25%{transform:rotateY(180deg) translateX(-7px)}
75%{transform:rotateY(180deg) translateX(7px)}
}
.face{
position:absolute;inset:0;border-radius:14px;
display:flex;align-items:center;justify-content:center;
backface-visibility:hidden;-webkit-backface-visibility:hidden;
user-select:none;
}
.back{
background:var(--card-back);
box-shadow:0 4px 12px rgba(0,0,0,.3), inset 0 0 0 2px rgba(255,255,255,.15);
}
.back::after{content:"?";font-size:clamp(1.2rem,5vw,2rem);font-weight:800;color:rgba(255,255,255,.85)}
.front{
background:var(--card-face);color:#111;
transform:rotateY(180deg);
font-size:clamp(1.4rem,6vw,2.8rem);
box-shadow:0 4px 12px rgba(0,0,0,.3);
}
.card.miss .front{background:var(--miss)}
.card.matched .front{background:var(--matched);animation:pop .4s ease}
@keyframes pop{0%{transform:rotateY(180deg) scale(1)}50%{transform:rotateY(180deg) scale(1.12)}100%{transform:rotateY(180deg) scale(1)}}
.overlay{
position:fixed;inset:0;background:rgba(15,12,41,.8);
display:flex;align-items:center;justify-content:center;
opacity:0;pointer-events:none;transition:opacity .35s;backdrop-filter:blur(4px);z-index:10;
}
.overlay.show{opacity:1;pointer-events:auto}
.win-card{
background:linear-gradient(160deg,#312e81,#1e1b4b);
border:1px solid rgba(255,255,255,.15);
padding:36px 40px;border-radius:24px;text-align:center;
box-shadow:0 20px 60px rgba(0,0,0,.5);transform:scale(.85);transition:transform .35s;
max-width:90vw;
}
.overlay.show .win-card{transform:scale(1)}
.win-card h2{font-size:2rem;margin-bottom:8px}
.win-card .emoji{font-size:3rem;margin-bottom:6px}
.win-card p{color:#cbd5e1;margin-bottom:6px;font-size:1.05rem}
.win-card p b{color:var(--accent)}
.win-card .newbest{color:#fcd34d;font-weight:700;margin-top:4px}
.win-card .btn{margin-top:18px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
footer{font-size:.8rem;color:var(--foot);margin-top:6px}
</style>
</head>
<body>
<h1>🧠 Emoji Memory Match</h1>
<div class="controls">
<div class="seg" id="difficulty" role="group" aria-label="Difficulty">
<button data-size="4" class="active" aria-pressed="true">4 × 4</button>
<button data-size="6" aria-pressed="false">6 × 6</button>
<button data-size="8" aria-pressed="false">8 × 8</button>
</div>
<button class="btn" id="restart">↻ Restart</button>
<button class="icon-btn" id="soundToggle" title="Toggle sound" aria-pressed="true">🔊</button>
<button class="icon-btn" id="themeToggle" title="Toggle light / dark theme">🌙</button>
</div>
<div class="stats" aria-hidden="true">
<div>Moves: <span id="moves">0</span></div>
<div>Time: <span id="time">0:00</span></div>
<div>Pairs: <span id="pairs">0 / 0</span></div>
<div class="best">Best: <span id="best">—</span></div>
</div>
<div class="board-wrap">
<div id="board" role="grid" aria-label="Memory board"></div>
</div>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>
<div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-labelledby="winTitle">
<div class="win-card">
<div class="emoji">🎉</div>
<h2 id="winTitle">You Won!</h2>
<p>Solved in <b id="winMoves">0</b> moves</p>
<p>Time: <b id="winTime">0:00</b></p>
<p class="newbest" id="newBest" hidden>🏆 New best time!</p>
<button class="btn" id="playAgain">Play Again</button>
</div>
</div>
<footer>Free-Roster · flip two cards to find a match · use mouse or keyboard</footer>
<script>
const EMOJIS = ['🍕','🎸','🚀','🐱','🌈','⚽','🎮','🍩','🦊','🌵','🐙','🎈',
'🍔','🦄','🎧','🍦','🐳','🔥','🌸','🎲','🧩','🍭','🐢','🎁',
'🐝','🌙','🍉','🎯','🦋','🍄','⭐','🐧','🍎','🐸','🎃','🌻',
'🍇','🐬','🎹','🍓','🦖','🌴','🍒','🐨','🎨','🍿','🦉','🌊'];
const board = document.getElementById('board');
const movesEl = document.getElementById('moves');
const timeEl = document.getElementById('time');
const pairsEl = document.getElementById('pairs');
const bestEl = document.getElementById('best');
const liveEl = document.getElementById('live');
const overlay = document.getElementById('overlay');
let size = 4, first = null, lock = false;
let moves = 0, matched = 0, totalPairs = 0;
let timer = null, seconds = 0, started = false;
let soundOn = localStorage.getItem('freeRoster.sound') !== 'off';
/* ---- self-contained Web Audio sound engine (no files) ---- */
let audioCtx = null;
function tone(freq, start, dur, type='sine', gain=0.18){
if(!audioCtx) return;
const o = audioCtx.createOscillator(), g = audioCtx.createGain();
o.type = type; o.frequency.value = freq;
o.connect(g); g.connect(audioCtx.destination);
const t0 = audioCtx.currentTime + start;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.start(t0); o.stop(t0 + dur);
}
function ensureAudio(){
if(!soundOn) return;
if(!audioCtx){
const AC = window.AudioContext || window.webkitAudioContext;
if(AC) audioCtx = new AC();
}
if(audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
}
const sfx = {
flip(){ if(soundOn){ ensureAudio(); tone(520, 0, 0.09, 'triangle', 0.14); } },
match(){ if(soundOn){ ensureAudio(); tone(660,0,0.12,'sine',0.18); tone(880,0.08,0.16,'sine',0.16); } },
miss(){ if(soundOn){ ensureAudio(); tone(200,0,0.18,'sawtooth',0.12); } },
win(){ if(soundOn){ ensureAudio(); [523,659,784,1047].forEach((f,i)=>tone(f, i*0.13, 0.28, 'sine', 0.18)); } }
};
const fmt = s => Math.floor(s/60) + ':' + String(s%60).padStart(2,'0');
const bestKey = () => 'freeRoster.best.' + size;
const say = msg => { liveEl.textContent = msg; };
function shuffle(a){
for(let i=a.length-1;i>0;i--){
const j = Math.floor(Math.random()*(i+1));
[a[i],a[j]]=[a[j],a[i]];
}
return a;
}
function loadBest(){
const raw = localStorage.getItem(bestKey());
if(!raw){ bestEl.textContent = '—'; return null; }
try{
const b = JSON.parse(raw);
bestEl.textContent = fmt(b.seconds) + ' · ' + b.moves + ' mv';
return b;
}catch{ bestEl.textContent = '—'; return null; }
}
function startTimer(){
if(started) return;
started = true;
timer = setInterval(()=>{ seconds++; timeEl.textContent = fmt(seconds); }, 1000);
}
function stopTimer(){ clearInterval(timer); timer = null; }
function newGame(){
stopTimer();
started = false; seconds = 0; moves = 0; matched = 0; first = null; lock = false;
totalPairs = (size*size)/2;
movesEl.textContent = '0';
timeEl.textContent = '0:00';
pairsEl.textContent = '0 / ' + totalPairs;
overlay.classList.remove('show');
board.classList.remove('locked');
loadBest();
const chosen = shuffle([...EMOJIS]).slice(0, totalPairs);
const deck = shuffle([...chosen, ...chosen]);
board.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
board.innerHTML = '';
deck.forEach((emoji)=>{
const card = document.createElement('button');
card.className = 'card';
card.type = 'button';
card.dataset.emoji = emoji;
card.setAttribute('aria-label', 'Hidden card');
card.innerHTML = `<span class="face back" aria-hidden="true"></span><span class="face front" aria-hidden="true">${emoji}</span>`;
card.addEventListener('click', ()=>flip(card));
board.appendChild(card);
});
say(size + ' by ' + size + ' game started. ' + totalPairs + ' pairs.');
}
function flip(card){
if(lock) return;
if(card.classList.contains('flipped') || card.classList.contains('matched')) return;
startTimer();
sfx.flip();
card.classList.add('flipped');
card.setAttribute('aria-label', card.dataset.emoji);
if(!first){ first = card; return; }
if(first === card) return;
moves++;
movesEl.textContent = moves;
if(first.dataset.emoji === card.dataset.emoji){
const a = first; first = null;
a.classList.add('matched');
card.classList.add('matched');
a.setAttribute('aria-label', a.dataset.emoji + ', matched');
card.setAttribute('aria-label', card.dataset.emoji + ', matched');
matched++;
pairsEl.textContent = matched + ' / ' + totalPairs;
say('Match! ' + matched + ' of ' + totalPairs + ' pairs.');
if(matched === totalPairs){ win(); } else { sfx.match(); }
} else {
lock = true;
board.classList.add('locked');
const a = first, b = card;
first = null;
a.classList.add('miss'); b.classList.add('miss');
sfx.miss();
say('No match.');
setTimeout(()=>{
[a,b].forEach(c=>{
c.classList.remove('flipped','miss');
c.setAttribute('aria-label','Hidden card');
});
lock = false;
board.classList.remove('locked');
}, 800);
}
}
function win(){
stopTimer();
const prev = loadBest();
const isBest = !prev || seconds < prev.seconds ||
(seconds === prev.seconds && moves < prev.moves);
if(isBest){
localStorage.setItem(bestKey(), JSON.stringify({seconds, moves}));
loadBest();
}
document.getElementById('winMoves').textContent = moves;
document.getElementById('winTime').textContent = fmt(seconds);
document.getElementById('newBest').hidden = !isBest;
say('You won in ' + moves + ' moves and ' + fmt(seconds) + '.');
sfx.win();
setTimeout(()=>{ overlay.classList.add('show'); document.getElementById('playAgain').focus(); }, 500);
}
document.getElementById('difficulty').addEventListener('click', (e)=>{
const b = e.target.closest('button');
if(!b) return;
document.querySelectorAll('#difficulty button').forEach(x=>{
const on = x === b;
x.classList.toggle('active', on);
x.setAttribute('aria-pressed', on ? 'true' : 'false');
});
size = parseInt(b.dataset.size, 10);
newGame();
});
document.getElementById('restart').addEventListener('click', newGame);
document.getElementById('playAgain').addEventListener('click', newGame);
// Close win overlay with Escape → restart
document.addEventListener('keydown', (e)=>{
if(e.key === 'Escape' && overlay.classList.contains('show')) newGame();
});
/* ---- sound toggle ---- */
const soundBtn = document.getElementById('soundToggle');
function renderSound(){
soundBtn.textContent = soundOn ? '🔊' : '🔇';
soundBtn.classList.toggle('off', !soundOn);
soundBtn.setAttribute('aria-pressed', soundOn ? 'true' : 'false');
}
soundBtn.addEventListener('click', ()=>{
soundOn = !soundOn;
localStorage.setItem('freeRoster.sound', soundOn ? 'on' : 'off');
renderSound();
if(soundOn){ ensureAudio(); sfx.flip(); }
});
renderSound();
/* ---- theme toggle ---- */
const themeBtn = document.getElementById('themeToggle');
function applyTheme(theme){
const light = theme === 'light';
document.body.classList.toggle('light', light);
themeBtn.textContent = light ? '☀️' : '🌙';
themeBtn.setAttribute('aria-label', light ? 'Switch to dark theme' : 'Switch to light theme');
}
applyTheme(localStorage.getItem('freeRoster.theme') || 'dark');
themeBtn.addEventListener('click', ()=>{
const next = document.body.classList.contains('light') ? 'dark' : 'light';
localStorage.setItem('freeRoster.theme', next);
applyTheme(next);
});
newGame();
</script>
</body>
</html>