number-memory-game-component
Version:
A simple number memory game implemented as a web component.
477 lines (422 loc) • 27.4 kB
JavaScript
class NumberMemoryGame extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.randomNumbers = [];
this.gameInterval = 3000;
this.roundTimerId = null;
this.feedbackTimerId = null;
this.roundsPlayed = 0;
this.totalRoundsPerSet = 5;
this.currentScore = 0;
this.incorrectAttemptsThisRound = 0;
this.maxIncorrectAttemptsPerRound = 2;
this.activeInputIndex = 0; // Index for the active guess display slot
this._startGame = this._startGame.bind(this);
this._submitGuess = this._submitGuess.bind(this);
this._resetGameToSettings = this._resetGameToSettings.bind(this);
this._handleNumpadClick = this._handleNumpadClick.bind(this);
}
connectedCallback() {
this._render();
this._attachEventListeners();
this._resetGameToSettings();
}
disconnectedCallback() {
if (this.roundTimerId) clearTimeout(this.roundTimerId);
if (this.feedbackTimerId) clearTimeout(this.feedbackTimerId);
this.startGameBtn.removeEventListener('click', this._startGame);
this.submitGuessBtn.removeEventListener('click', this._submitGuess);
this.playAgainBtn.removeEventListener('click', this._resetGameToSettings);
this.numpad.removeEventListener('click', this._handleNumpadClick);
// No specific listeners on guess display slots to remove, as they don't take direct input
}
_render() {
this.shadowRoot.innerHTML = `
<link href="https://cdn.tailwindcss.com" rel="stylesheet">
<style>
:host {
display: block;
}
.game-container {
font-family: 'Inter', sans-serif;
background-image: linear-gradient(to bottom right, #0f172a, #1e293b);
color: white;
padding: 1.5rem;
}
(min-width: 768px) {
.game-container {
padding: 2.5rem;
}
}
.number-display-item {
transition: opacity 0.5s ease-in-out;
}
/* Styling for the new guess display slots */
.guess-display-slot {
display: inline-flex; /* To allow width and height */
justify-content: center;
align-items: center;
width: 30%; /* Similar to w-1/3 */
min-height: 3rem; /* p-3 equivalent height */
padding: 0.75rem; /* p-3 */
text-align: center;
font-size: 1.5rem; /* text-2xl */
background-color: #334155; /* bg-slate-700 */
border: 1px solid #475569; /* border-slate-600 */
border-radius: 0.5rem; /* rounded-lg */
color: #ffffff; /* text-white */
transition: border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
box-sizing: border-box;
}
.guess-display-slot.focused {
border-color: #60a5fa; /* Tailwind's blue-400 */
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.5);
}
.guess-display-slot.border-green-500 { border-color: #22c55e !important; }
.guess-display-slot.border-red-500 { border-color: #ef4444 !important; }
.message-box {
min-height: 5.5rem;
}
.numpad-btn {
width: 100%;
padding: 0.75rem;
background-color: #334155;
border-radius: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: white;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.numpad-btn:hover {
background-color: #475569;
}
.numpad-btn:focus { /* Basic focus for accessibility, can be enhanced */
outline: 2px solid #38bdf8;
outline-offset: 2px;
}
/* Minimal set of direct Tailwind translations for core functionality */
.hidden { display: none; }
.flex { display: flex; }
.justify-around { justify-content: space-around; }
.items-center { align-items: center; }
.text-center { text-align: center; }
.font-bold { font-weight: 700; }
.w-full { width: 100%; }
.mt-4 { margin-top: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
.p-3 { padding: 0.75rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.rounded-lg { border-radius: 0.5rem; }
.text-sky-400 { color: #38bdf8; }
.text-slate-300 { color: #cbd5e1; }
.text-slate-400 { color: #94a3b8; }
.bg-slate-700 { background-color: #334155; }
.border-slate-600 { border-color: #475569; }
.bg-sky-500 { background-color: #0ea5e9; }
.hover\:bg-sky-600:hover { background-color: #0284c7; }
.bg-green-500 { background-color: #22c55e; }
.hover\:bg-green-600:hover { background-color: #16a34a; }
.bg-red-700 { background-color: #b91c1c; }
.hover\:bg-red-600:hover { background-color: #991b1b; }
.bg-sky-700 { background-color: #0369a1; }
.hover\:bg-sky-600:hover { background-color: #075985; }
.grid { display: grid; }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.gap-2 { gap: 0.5rem; }
.text-3xl { font-size: 1.875rem; }
.md\:text-4xl { (min-width: 768px) { font-size: 2.25rem; } }
.text-sm { font-size: 0.875rem; }
.font-medium { font-weight: 500; }
.min-h-\\[80px\\] { min-height: 80px; }
.space-y-4 > :not([hidden]) ~ :not([hidden]) { margin-top: 1rem; }
.space-x-2 > :not([hidden]) ~ :not([hidden]) { margin-left: 0.5rem; }
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) { (min-width: 640px) { margin-left: 1rem; } }
.shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06); }
.hover\:shadow-lg:hover { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); }
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.duration-150 { transition-duration: 150ms; }
.ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
.focus\:ring-2:focus { box-shadow: 0 0 0 2px var(--tw-ring-color, #38bdf8); }
.focus\:ring-sky-400:focus { --tw-ring-color: #38bdf8; }
.focus\:ring-green-400:focus { --tw-ring-color: #4ade80; }
.focus\:ring-opacity-75:focus { /* Opacity might need JS if not using full Tailwind */ }
.bg-green-500\\/20 { background-color: rgba(34, 197, 94, 0.2); }
.text-green-400 { color: #4ade80; }
.bg-red-500\\/20 { background-color: rgba(239, 68, 68, 0.2); }
.text-red-400 { color: #f87171; }
</style>
<div class="game-container">
<header class="mb-6 text-center">
<h1 class="text-3xl md:text-4xl font-bold text-sky-400">Provocarea Memoriei Numerelor</h1>
<p class="text-slate-400 mt-2">Testează-ți memoria pe termen scurt pe parcursul a 5 runde!</p>
</header>
<section id="settingsSection" class="mb-6">
<label for="intervalInput" class="block text-sm font-medium text-slate-300 mb-1">Interval (secunde pe rundă):</label>
<input type="number" id="intervalInput" value="3" min="1" max="10" class="w-full p-3 bg-slate-700 border border-slate-600 rounded-lg text-white focus:ring-2 focus:ring-sky-500" placeholder="ex: 3">
<button id="startGameBtn" class="mt-4 w-full bg-sky-500 hover:bg-sky-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-opacity-75">
Începe 5 Runde
</button>
</section>
<section id="gameSection" class="hidden">
<div id="numberDisplay" class="flex justify-around items-center my-8 p-4 bg-slate-700 rounded-lg min-h-[80px]">
</div>
<div id="guessInputArea" class="hidden mt-6 space-y-4">
<p id="guessPrompt" class="text-slate-300 text-center">Numerele au dispărut! Introdu ce ai reținut:</p>
<div class="flex justify-around space-x-2 sm:space-x-4">
<span id="guessDisplay1" data-index="0" class="guess-display-slot" aria-label="Primul număr ghicit">-</span>
<span id="guessDisplay2" data-index="1" class="guess-display-slot" aria-label="Al doilea număr ghicit">-</span>
<span id="guessDisplay3" data-index="2" class="guess-display-slot" aria-label="Al treilea număr ghicit">-</span>
</div>
<button id="submitGuessBtn" class="w-full bg-green-500 hover:bg-green-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-75">
Trimite Răspuns
</button>
<div id="numpad" class="mt-4 grid grid-cols-3 gap-2">
<button class="numpad-btn" data-value="1">1</button>
<button class="numpad-btn" data-value="2">2</button>
<button class="numpad-btn" data-value="3">3</button>
<button class="numpad-btn" data-value="4">4</button>
<button class="numpad-btn" data-value="5">5</button>
<button class="numpad-btn" data-value="6">6</button>
<button class="numpad-btn" data-value="7">7</button>
<button class="numpad-btn" data-value="8">8</button>
<button class="numpad-btn" data-value="9">9</button>
<button class="numpad-btn bg-red-700 hover:bg-red-600" data-value="clear">Șterge</button>
<button class="numpad-btn" data-value="0">0</button>
<button class="numpad-btn bg-sky-700 hover:bg-sky-600" data-value="next">Următor</button>
</div>
</div>
</section>
<div id="messageArea" class="mt-6 text-center message-box">
</div>
<button id="playAgainBtn" class="hidden mt-4 w-full bg-sky-500 hover:bg-sky-600 text-white font-semibold py-3 px-4 rounded-lg shadow-md hover:shadow-lg transition-all duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-opacity-75">
Joacă un Set Nou
</button>
</div>
`;
this._getDOMReferences();
}
_getDOMReferences() {
this.intervalInput = this.shadowRoot.getElementById('intervalInput');
this.startGameBtn = this.shadowRoot.getElementById('startGameBtn');
this.settingsSection = this.shadowRoot.getElementById('settingsSection');
this.gameSection = this.shadowRoot.getElementById('gameSection');
this.numberDisplay = this.shadowRoot.getElementById('numberDisplay');
this.guessInputArea = this.shadowRoot.getElementById('guessInputArea');
this.guessPrompt = this.shadowRoot.getElementById('guessPrompt');
// guessInputs acum se referă la elementele <span>
this.guessInputs = [
this.shadowRoot.getElementById('guessDisplay1'),
this.shadowRoot.getElementById('guessDisplay2'),
this.shadowRoot.getElementById('guessDisplay3')
];
this.submitGuessBtn = this.shadowRoot.getElementById('submitGuessBtn');
this.messageArea = this.shadowRoot.getElementById('messageArea');
this.playAgainBtn = this.shadowRoot.getElementById('playAgainBtn');
this.numpad = this.shadowRoot.getElementById('numpad');
}
_attachEventListeners() {
this.startGameBtn.addEventListener('click', this._startGame);
this.submitGuessBtn.addEventListener('click', this._submitGuess);
this.playAgainBtn.addEventListener('click', this._resetGameToSettings);
this.numpad.addEventListener('click', this._handleNumpadClick);
// Adaugă event listeners la sloturile de afișare pentru a le seta ca active la click
// Acest lucru îmbunătățește UX-ul dacă utilizatorul dorește să schimbe un slot specific cu numpad-ul
this.guessInputs.forEach((slot, index) => {
slot.addEventListener('click', () => this._setActiveInput(index));
});
}
_setActiveInput(index) {
this.guessInputs.forEach(slot => slot.classList.remove('focused'));
if (index >= 0 && index < this.guessInputs.length) {
this.guessInputs[index].classList.add('focused');
// Nu mai apelăm .focus() deoarece span-urile nu au această metodă în mod implicit
this.activeInputIndex = index;
}
}
_generateRandomNumbers() {
const numbers = new Set();
while (numbers.size < 3) {
numbers.add(Math.floor(Math.random() * 10));
}
this.randomNumbers = Array.from(numbers);
}
_displayNumbers() {
this.numberDisplay.innerHTML = this.randomNumbers.map(num =>
`<span class="text-4xl md:text-5xl font-bold text-sky-400 number-display-item opacity-100">${num}</span>`
).join('');
void this.numberDisplay.offsetWidth;
this.numberDisplay.querySelectorAll('.number-display-item').forEach(item => item.classList.add('opacity-100'));
}
_hideNumbersAndEnableGuessing() {
const numberElements = this.numberDisplay.querySelectorAll('.number-display-item');
numberElements.forEach(el => el.classList.remove('opacity-100'));
numberElements.forEach(el => el.classList.add('opacity-0'));
setTimeout(() => {
this.numberDisplay.innerHTML = `<span class="text-slate-400">Le-ai reținut?</span>`;
this.guessPrompt.textContent = "Numerele au dispărut! Introdu ce ai reținut:";
this.guessInputArea.classList.remove('hidden');
this.guessInputs.forEach(slot => slot.textContent = '-'); // Placeholder inițial
this._setActiveInput(0);
this.submitGuessBtn.disabled = false;
}, 500);
}
_startNewRound() {
if (this.roundTimerId) clearTimeout(this.roundTimerId);
if (this.feedbackTimerId) clearTimeout(this.feedbackTimerId);
this.incorrectAttemptsThisRound = 0;
this.guessInputArea.classList.add('hidden');
this.submitGuessBtn.disabled = true;
this.playAgainBtn.classList.add('hidden');
this.guessInputs.forEach(slot => {
slot.classList.remove('border-red-500', 'border-green-500', 'focused');
slot.textContent = '-'; // Resetează la placeholder
});
this._showMessage(`Runda ${this.roundsPlayed + 1} din ${this.totalRoundsPerSet}.<br>Pregătește-te să memorezi...`, 'info');
this._generateRandomNumbers();
this._displayNumbers();
this.roundTimerId = setTimeout(() => this._hideNumbersAndEnableGuessing(), this.gameInterval);
}
_resetGameToSettings() {
if (this.roundTimerId) clearTimeout(this.roundTimerId);
if (this.feedbackTimerId) clearTimeout(this.feedbackTimerId);
this.randomNumbers = [];
this.roundsPlayed = 0;
this.currentScore = 0;
this.incorrectAttemptsThisRound = 0;
this.activeInputIndex = 0;
this.settingsSection.classList.remove('hidden');
this.gameSection.classList.add('hidden');
this.guessInputArea.classList.add('hidden');
this.playAgainBtn.classList.add('hidden');
this.messageArea.innerHTML = '';
this.numberDisplay.innerHTML = '';
this.startGameBtn.disabled = false;
this.startGameBtn.textContent = 'Începe 5 Runde';
this.intervalInput.disabled = false;
this.guessInputs.forEach(slot => {
slot.textContent = '-';
slot.classList.remove('border-red-500', 'border-green-500', 'focused');
});
}
_showMessage(htmlContent, type = 'info') {
this.messageArea.innerHTML = `<div class="p-3 rounded-lg ${
type === 'success' ? 'bg-green-500/20 text-green-400' :
type === 'error' ? 'bg-red-500/20 text-red-400' :
'bg-slate-700 text-slate-300'
}">${htmlContent}</div>`;
}
_handleRoundEnd(isCorrectGuess) {
let roundFeedbackMessage = '';
if (isCorrectGuess) {
this.currentScore++;
roundFeedbackMessage = `Runda ${this.roundsPlayed + 1}/${this.totalRoundsPerSet}: Corect! <br>Numerele au fost ${this.randomNumbers.join(', ')}.`;
this._showMessage(roundFeedbackMessage, 'success');
} else {
roundFeedbackMessage = `Runda ${this.roundsPlayed + 1}/${this.totalRoundsPerSet}: Prea multe încercări! <br>Numerele corecte erau ${this.randomNumbers.join(', ')}.`;
this._showMessage(roundFeedbackMessage, 'error');
}
this.roundsPlayed++;
if (this.feedbackTimerId) clearTimeout(this.feedbackTimerId);
if (this.roundsPlayed < this.totalRoundsPerSet) {
this.feedbackTimerId = setTimeout(() => {
this._startNewRound();
}, 3000);
} else {
let finalMessage = `Set Complet! Scorul Final: ${this.currentScore} din ${this.totalRoundsPerSet} runde corecte.<br>`;
if (!isCorrectGuess && this.incorrectAttemptsThisRound >= this.maxIncorrectAttemptsPerRound) {
finalMessage += `Ultimele numere (runda ${this.roundsPlayed}) au fost ${this.randomNumbers.join(', ')}.`;
} else if (isCorrectGuess) {
finalMessage += `Ultimele numere corect ghicite (runda ${this.roundsPlayed}) au fost ${this.randomNumbers.join(', ')}.`;
}
this._showMessage(finalMessage, this.currentScore >= Math.ceil(this.totalRoundsPerSet / 2) ? 'success' : 'error');
this.playAgainBtn.classList.remove('hidden');
// this.playAgainBtn.focus(); // Button focus is fine
this.startGameBtn.textContent = 'Începe 5 Runde';
this.startGameBtn.disabled = true;
this.intervalInput.disabled = true;
}
}
_startGame() {
const intervalValue = parseInt(this.intervalInput.value, 10);
if (isNaN(intervalValue) || intervalValue < 1 || intervalValue > 10) {
this._showMessage('Te rog introdu un interval valid (1-10 secunde).', 'error');
// this.intervalInput.focus(); // Input focus is fine
return;
}
this.gameInterval = intervalValue * 1000;
this.roundsPlayed = 0;
this.currentScore = 0;
this.settingsSection.classList.add('hidden');
this.gameSection.classList.remove('hidden');
this.startGameBtn.disabled = true;
this.startGameBtn.textContent = 'Set În Desfășurare...';
this.intervalInput.disabled = true;
this._startNewRound();
}
_submitGuess() {
// Citim valorile din textContent-ul span-urilor
const userGuesses = this.guessInputs.map(slot => {
const val = slot.textContent;
// Dacă slotul conține placeholder-ul '-', considerăm NaN
return (val === '' || val === '-') ? NaN : parseInt(val, 10);
});
if (userGuesses.some(isNaN)) {
this._showMessage('Te rog completează toate căsuțele folosind tastatura numerică.', 'error');
return;
}
this.submitGuessBtn.disabled = true;
let allCorrectInRound = true;
for (let i = 0; i < 3; i++) {
this.guessInputs[i].classList.remove('border-red-500', 'border-green-500');
if (userGuesses[i] === this.randomNumbers[i]) {
this.guessInputs[i].classList.add('border-green-500');
} else {
allCorrectInRound = false;
this.guessInputs[i].classList.add('border-red-500');
}
}
if (allCorrectInRound) {
this._handleRoundEnd(true);
} else {
this.incorrectAttemptsThisRound++;
if (this.incorrectAttemptsThisRound < this.maxIncorrectAttemptsPerRound) {
this._showMessage(`Încercarea ${this.incorrectAttemptsThisRound}/${this.maxIncorrectAttemptsPerRound} greșită. Mai încearcă!<br>Numerele introduse: ${userGuesses.map(g => isNaN(g) ? '-' : g).join(', ')}`, 'error');
this.guessPrompt.textContent = `Mai încearcă! (Încercarea ${this.incorrectAttemptsThisRound + 1}/${this.maxIncorrectAttemptsPerRound})`;
this.submitGuessBtn.disabled = false;
this._setActiveInput(0);
} else {
this._handleRoundEnd(false);
}
}
}
_handleNumpadClick(event) {
const target = event.target.closest('button');
if (!target) return;
const value = target.dataset.value;
const currentSlot = this.guessInputs[this.activeInputIndex];
if (value === 'clear') {
currentSlot.textContent = '-'; // Resetează la placeholder
// Nu este nevoie de .focus() pe span
} else if (value === 'next') {
let nextIndex = (this.activeInputIndex + 1) % this.guessInputs.length;
this._setActiveInput(nextIndex);
} else if (value) { // Este o cifră (0-9)
currentSlot.textContent = value; // Setează textContent-ul span-ului
if (this.activeInputIndex < this.guessInputs.length - 1) {
this._setActiveInput(this.activeInputIndex + 1);
} else {
// Rămâne pe ultimul slot, dar este actualizat
this._setActiveInput(this.activeInputIndex); // Reaplică clasa focused
}
}
}
}
customElements.define('number-memory-game', NumberMemoryGame);