parent
08b771c863
commit
b303f9586c
@ -0,0 +1,348 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Card Memory Game</title>
|
||||
<style>
|
||||
:root{ --bg:#0f1724; --card:#0b1220; --accent:#7c3aed; --glass: rgba(255,255,255,0.04); }
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial; margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:linear-gradient(180deg,#071021 0%, #061225 60%); color:#e6eef8}
|
||||
.app{width:100%; max-width:1100px; margin:28px;}
|
||||
header{display:flex; gap:18px; align-items:center; justify-content:space-between}
|
||||
|
||||
.brand{display:flex; gap:14px; align-items:center}
|
||||
.logo{width:56px;height:56px;border-radius:12px;background:linear-gradient(135deg,var(--accent),#0ea5a2);display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
h1{font-size:20px;margin:0}
|
||||
p.lead{margin:0;color:#9fb0c8}
|
||||
|
||||
.controls{display:flex;gap:12px;align-items:center}
|
||||
select, button, input[type=number]{padding:8px 10px;border-radius:8px;border:0;background:var(--glass);color:inherit}
|
||||
button.primary{background:var(--accent); color:white; border:0; padding:10px 14px; cursor:pointer}
|
||||
.secondary{background:transparent;border:1px solid rgba(255,255,255,0.06)}
|
||||
|
||||
.meta{display:flex;gap:14px;align-items:center;margin-top:16px}
|
||||
.meta .stat{background:rgba(255,255,255,0.03);padding:10px;border-radius:10px;min-width:120px;text-align:center}
|
||||
.meta .stat small{display:block;color:#9fb0c8}
|
||||
|
||||
main{margin-top:18px; display:grid; grid-template-columns: 1fr 320px; gap:18px}
|
||||
|
||||
/* Board */
|
||||
.board-wrap{background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); padding:18px; border-radius:14px}
|
||||
.board{display:grid; gap:12px; justify-content:center}
|
||||
|
||||
.card{width:100%; aspect-ratio:1/1; perspective:800px}
|
||||
.card-inner{position:relative;width:100%;height:100%;transform-style:preserve-3d;transition: transform 350ms cubic-bezier(.2,.8,.2,1)}
|
||||
.card.flipped .card-inner{transform: rotateY(180deg)}
|
||||
.face{position:absolute;inset:0;border-radius:10px;display:flex;align-items:center;justify-content:center;backface-visibility:hidden;box-shadow:0 6px 18px rgba(2,6,23,0.6)}
|
||||
.front{background:linear-gradient(180deg,#0b1220,#071020); color:#cfe9ff; font-size:28px}
|
||||
.back{background:linear-gradient(180deg,#fff 0%, #fff 1%); transform:rotateY(180deg); color:#071020; font-size:34px}
|
||||
|
||||
.side-panel{background:rgba(255,255,255,0.02); padding:18px; border-radius:14px; height:100%}
|
||||
.panel-section{margin-bottom:12px}
|
||||
.level-buttons{display:flex;gap:8px}
|
||||
.level-buttons button{flex:1}
|
||||
|
||||
.stats-list{display:grid;gap:8px}
|
||||
.stats-item{background:rgba(255,255,255,0.02); padding:10px;border-radius:8px}
|
||||
|
||||
footer{margin-top:16px;color:#7f98b1;font-size:13px}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width:900px){ main{grid-template-columns:1fr} .side-panel{order:2} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">CM</div>
|
||||
<div>
|
||||
<h1>Card Memory Game</h1>
|
||||
<p class="lead">Find matching pairs — timer, difficulty levels and stats saved locally.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label>
|
||||
Difficulty
|
||||
<select id="difficulty">
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Grid size
|
||||
<input id="gridSize" type="number" min="2" max="8" value="4" />
|
||||
</label>
|
||||
<button id="startBtn" class="primary">Start Game</button>
|
||||
<button id="resetStats" class="secondary" title="Clear stats">Reset Stats</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="meta">
|
||||
<div class="stat"><small>Elapsed</small><div id="elapsed">00:00</div></div>
|
||||
<div class="stat"><small>Time left</small><div id="timeLeft">—</div></div>
|
||||
<div class="stat"><small>Moves</small><div id="moves">0</div></div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="board-wrap">
|
||||
<div id="board" class="board"></div>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel">
|
||||
<div class="panel-section">
|
||||
<h3>Controls</h3>
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button id="shuffleBtn" class="secondary">Shuffle</button>
|
||||
<button id="cheatBtn" class="secondary">Show / Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Difficulty presets</h4>
|
||||
<div class="level-buttons">
|
||||
<button data-level="easy">Easy</button>
|
||||
<button data-level="medium">Medium</button>
|
||||
<button data-level="hard">Hard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>Stats</h4>
|
||||
<div class="stats-list" id="statsList">
|
||||
<!-- stat rows injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h4>How to play</h4>
|
||||
<p style="font-size:13px;color:#9fb0c8">Click Start → flip two cards. If they match they stay revealed. Finish before time runs out (hard mode).</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer>Built with HTML, CSS and vanilla JavaScript • Emoji icons used as card faces (no external images)</footer>
|
||||
</div>
|
||||
|
||||
<!-- Congrats modal (simple) -->
|
||||
<dialog id="congrats">
|
||||
<h3>Congratulations 🎉</h3>
|
||||
<p>You finished the game in <strong id="finalTime"></strong> with <span id="finalMoves"></span> moves.</p>
|
||||
<menu>
|
||||
<button id="playAgain">Play Again</button>
|
||||
<button id="closeModal">Close</button>
|
||||
</menu>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
// ======= Config & state ======
|
||||
const EMOJIS = ['🍎','🍌','🍇','🍓','🍒','🍍','🥝','🥑','🍑','🍋','🍉','🍊','🍐','🥥','🍈','🍏','🥕','🌽','🥔','🍆','🌶️','🍄','🥦','🥐'];
|
||||
|
||||
const difficultyPresets = {
|
||||
easy: { grid: 4, timeLimit: 120 },
|
||||
medium: { grid: 6, timeLimit: 90 },
|
||||
hard: { grid: 8, timeLimit: 60 }
|
||||
};
|
||||
|
||||
let state = {
|
||||
gridSize: 4,
|
||||
cards: [],
|
||||
first: null,
|
||||
second: null,
|
||||
moves: 0,
|
||||
matchesFound: 0,
|
||||
started: false,
|
||||
elapsed: 0,
|
||||
timeLeft: null,
|
||||
timerInterval: null,
|
||||
countdownInterval: null,
|
||||
difficulty: 'easy'
|
||||
};
|
||||
|
||||
// ======= DOM ======
|
||||
const boardEl = document.getElementById('board');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const difficultyEl = document.getElementById('difficulty');
|
||||
const gridSizeEl = document.getElementById('gridSize');
|
||||
const elapsedEl = document.getElementById('elapsed');
|
||||
const timeLeftEl = document.getElementById('timeLeft');
|
||||
const movesEl = document.getElementById('moves');
|
||||
const statsListEl = document.getElementById('statsList');
|
||||
const congratsDialog = document.getElementById('congrats');
|
||||
const finalTimeEl = document.getElementById('finalTime');
|
||||
const finalMovesEl = document.getElementById('finalMoves');
|
||||
const playAgainBtn = document.getElementById('playAgain');
|
||||
const closeModalBtn = document.getElementById('closeModal');
|
||||
const shuffleBtn = document.getElementById('shuffleBtn');
|
||||
const cheatBtn = document.getElementById('cheatBtn');
|
||||
const resetStatsBtn = document.getElementById('resetStats');
|
||||
|
||||
// ======= Local storage for stats ======
|
||||
const STATS_KEY = 'cmgame_stats_v1';
|
||||
function getStats(){
|
||||
try{ return JSON.parse(localStorage.getItem(STATS_KEY)) || defaultStats(); }catch(e){ return defaultStats(); }
|
||||
}
|
||||
function defaultStats(){ return { easy:{wins:0,losses:0,bestTime:null}, medium:{wins:0,losses:0,bestTime:null}, hard:{wins:0,losses:0,bestTime:null} } }
|
||||
function saveStats(s){ localStorage.setItem(STATS_KEY, JSON.stringify(s)); renderStats(); }
|
||||
|
||||
function renderStats(){
|
||||
const s = getStats(); statsListEl.innerHTML='';
|
||||
for(const level of ['easy','medium','hard']){
|
||||
const it = document.createElement('div'); it.className='stats-item';
|
||||
it.innerHTML = `<strong>${level.toUpperCase()}</strong>
|
||||
<div style="font-size:13px;color:#9fb0c8;margin-top:6px">Wins: ${s[level].wins} • Losses: ${s[level].losses}<br>Best: ${s[level].bestTime ? formatTime(s[level].bestTime) : '—'}</div>`;
|
||||
statsListEl.appendChild(it);
|
||||
}
|
||||
}
|
||||
|
||||
// ======= Utilities ======
|
||||
function shuffle(array){
|
||||
for(let i=array.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [array[i],array[j]]=[array[j],array[i]] }
|
||||
return array;
|
||||
}
|
||||
function formatTime(seconds){ const m=Math.floor(seconds/60); const s=seconds%60; return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` }
|
||||
|
||||
// ======= Game setup ======
|
||||
function buildDeck(gridSize){
|
||||
const total = gridSize*gridSize;
|
||||
if(total % 2 !== 0) throw new Error('Grid must have even number of cells');
|
||||
const pairCount = total/2;
|
||||
const pool = shuffle(EMOJIS.slice());
|
||||
const selected = pool.slice(0,pairCount);
|
||||
const deck = shuffle([...selected, ...selected]);
|
||||
return deck.map((face, idx)=>({id:idx, face, matched:false, revealed:false}));
|
||||
}
|
||||
|
||||
function renderBoard(){
|
||||
const g = state.gridSize;
|
||||
boardEl.style.gridTemplateColumns = `repeat(${g}, minmax(60px, 1fr))`;
|
||||
boardEl.innerHTML='';
|
||||
state.cards.forEach(card=>{
|
||||
const c = document.createElement('div'); c.className='card' + (card.revealed || card.matched ? ' flipped': ''); c.dataset.id = card.id;
|
||||
c.innerHTML = `
|
||||
<div class="card-inner">
|
||||
<div class="face front">?</div>
|
||||
<div class="face back">${card.face}</div>
|
||||
</div>
|
||||
`;
|
||||
c.addEventListener('click', ()=> onCardClick(card.id));
|
||||
boardEl.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
// ======= Game logic ======
|
||||
function onCardClick(id){
|
||||
if(!state.started) return; // ignore if not started
|
||||
const card = state.cards.find(c=>c.id==id);
|
||||
if(!card || card.revealed || card.matched) return;
|
||||
// reveal
|
||||
card.revealed = true; renderBoard();
|
||||
if(!state.first){ state.first = card; return }
|
||||
if(state.first && !state.second && card.id !== state.first.id){ state.second = card; state.moves++; movesEl.textContent = state.moves; checkForMatch(); }
|
||||
}
|
||||
|
||||
function checkForMatch(){
|
||||
if(!state.first || !state.second) return;
|
||||
if(state.first.face === state.second.face){
|
||||
// match
|
||||
state.first.matched = true; state.second.matched = true; state.matchesFound +=2;
|
||||
state.first = null; state.second = null;
|
||||
renderBoard();
|
||||
if(state.matchesFound === state.cards.length) winGame();
|
||||
} else {
|
||||
// no match -> flip back after short delay
|
||||
const f1 = state.first.id, f2 = state.second.id;
|
||||
setTimeout(()=>{
|
||||
const a = state.cards.find(c=>c.id===f1);
|
||||
const b = state.cards.find(c=>c.id===f2);
|
||||
if(a) a.revealed=false; if(b) b.revealed=false; state.first=null; state.second=null; renderBoard();
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
// ======= Timers ======
|
||||
function startTimers(){
|
||||
// elapsed
|
||||
clearInterval(state.timerInterval); state.elapsed = 0; elapsedEl.textContent = '00:00';
|
||||
state.timerInterval = setInterval(()=>{ state.elapsed++; elapsedEl.textContent = formatTime(state.elapsed); }, 1000);
|
||||
// countdown
|
||||
if(state.timeLeft !== null){ clearInterval(state.countdownInterval); timeLeftEl.textContent = formatTime(state.timeLeft);
|
||||
state.countdownInterval = setInterval(()=>{
|
||||
state.timeLeft--; timeLeftEl.textContent = formatTime(Math.max(0,state.timeLeft));
|
||||
if(state.timeLeft <= 0){ clearInterval(state.countdownInterval); loseGame(); }
|
||||
}, 1000);
|
||||
} else { timeLeftEl.textContent = '—' }
|
||||
}
|
||||
function stopTimers(){ clearInterval(state.timerInterval); clearInterval(state.countdownInterval); }
|
||||
|
||||
// ======= Start / End ======
|
||||
function startGame(){
|
||||
// read inputs
|
||||
const preset = difficultyEl.value;
|
||||
const grid = parseInt(gridSizeEl.value,10) || difficultyPresets[preset].grid;
|
||||
if((grid*grid) % 2 !== 0){ alert('Grid must contain an even number of cells. Choose an even size.'); return }
|
||||
state.difficulty = preset;
|
||||
state.gridSize = grid;
|
||||
state.cards = buildDeck(grid);
|
||||
state.first = state.second = null; state.moves=0; state.matchesFound=0; state.started=true; state.elapsed=0;
|
||||
|
||||
// time limit based on difficulty: if grid too small, allow more time
|
||||
const baseLimit = difficultyPresets[preset]?.timeLimit ?? null;
|
||||
state.timeLeft = baseLimit ? Math.max(10, Math.floor(baseLimit * (grid/ difficultyPresets[preset].grid))) : null;
|
||||
|
||||
movesEl.textContent = state.moves;
|
||||
renderBoard(); startTimers();
|
||||
}
|
||||
|
||||
function winGame(){ stopTimers(); state.started=false; finalTimeEl.textContent = formatTime(state.elapsed); finalMovesEl.textContent = state.moves; congratsDialog.showModal();
|
||||
// save stats
|
||||
const s = getStats(); s[state.difficulty].wins += 1; if(!s[state.difficulty].bestTime || state.elapsed < s[state.difficulty].bestTime) s[state.difficulty].bestTime = state.elapsed; saveStats(s);
|
||||
}
|
||||
function loseGame(){ stopTimers(); state.started=false; alert('Time is up — you lost.'); const s = getStats(); s[state.difficulty].losses += 1; saveStats(s); }
|
||||
|
||||
// ======= UI bindings ======
|
||||
startBtn.addEventListener('click', ()=> startGame());
|
||||
|
||||
difficultyEl.addEventListener('change', e=>{
|
||||
// change grid size default to preset if user hasn't modified
|
||||
const val = e.target.value; const preset = difficultyPresets[val]; if(preset){ gridSizeEl.value = preset.grid; }
|
||||
});
|
||||
|
||||
document.querySelectorAll('.level-buttons button').forEach(b=> b.addEventListener('click', ()=>{ difficultyEl.value = b.dataset.level; difficultyEl.dispatchEvent(new Event('change')); }));
|
||||
|
||||
playAgainBtn.addEventListener('click', ()=>{ congratsDialog.close(); startGame(); });
|
||||
closeModalBtn.addEventListener('click', ()=> congratsDialog.close());
|
||||
|
||||
shuffleBtn.addEventListener('click', ()=>{ if(state.started) return; if(state.cards.length){ state.cards = shuffle(state.cards); renderBoard(); } });
|
||||
cheatBtn.addEventListener('click', ()=>{
|
||||
// toggle show all briefly
|
||||
if(!state.cards.length) return;
|
||||
const anyHidden = state.cards.some(c=>!c.revealed && !c.matched);
|
||||
if(anyHidden){ state.cards.forEach(c=>{ if(!c.matched) c.revealed = true }); renderBoard(); setTimeout(()=>{ state.cards.forEach(c=>{ if(!c.matched) c.revealed=false }); renderBoard(); }, 900); }
|
||||
else{ state.cards.forEach(c=>{ if(!c.matched) c.revealed=false }); renderBoard(); }
|
||||
});
|
||||
|
||||
resetStatsBtn.addEventListener('click', ()=>{ if(confirm('Clear saved stats?')){ localStorage.removeItem(STATS_KEY); renderStats(); } });
|
||||
|
||||
// persist grid size to input local memory (optional)
|
||||
|
||||
// init
|
||||
(function init(){
|
||||
renderStats(); // set default grid by difficulty
|
||||
gridSizeEl.value = difficultyPresets[difficultyEl.value].grid;
|
||||
// ensure initial empty board
|
||||
state.gridSize = parseInt(gridSizeEl.value,10);
|
||||
state.cards = buildDeck(state.gridSize);
|
||||
state.cards.forEach(c=>c.revealed=false);
|
||||
renderBoard();
|
||||
})();
|
||||
|
||||
// small accessibility: allow closing dialog with escape
|
||||
window.addEventListener('keydown', e=>{ if(e.key==='Escape' && congratsDialog.open) congratsDialog.close(); })
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in new issue