/* ========================================================================= ULTIMATE TIC-TAC-TOE — game engine - Per-player CAMPS: each player picks any avatar; their color/sound/FX follow that avatar's camp (sparkle or shred). Players can mix freely. - Global STAGE: sparkle / shred / mixed → backdrop, fonts, winner vibe. - Hot-seat two-player, accessible, sound + particle FX, persistence. ========================================================================= */ 'use strict'; /* ---------- Avatar roster (each tagged with its camp) ---------- */ const AVATARS = [ // Sparkle camp { emoji: '⭐', camp: 'sparkle' }, { emoji: '🦄', camp: 'sparkle' }, { emoji: '🌸', camp: 'sparkle' }, { emoji: '🎀', camp: 'sparkle' }, { emoji: '🦋', camp: 'sparkle' }, { emoji: '🌈', camp: 'sparkle' }, { emoji: '🎨', camp: 'sparkle' }, { emoji: '🍭', camp: 'sparkle' }, // Shred camp { emoji: '💀', camp: 'shred' }, { emoji: '☠️', camp: 'shred' }, { emoji: '🎸', camp: 'shred' }, { emoji: '🤘', camp: 'shred' }, { emoji: '🔥', camp: 'shred' }, { emoji: '⚡', camp: 'shred' }, { emoji: '🏴‍☠️', camp: 'shred' }, { emoji: '🐉', camp: 'shred' }, ]; const campOf = (emoji) => (AVATARS.find((a) => a.emoji === emoji) || AVATARS[0]).camp; /* ---------- Per-camp flavor (color is in CSS; here: sound + particles) ---------- */ const CAMPS = { sparkle: { particles: ['✨', '💫', '⭐', '🌟'], sound: { wave: 'sine', move: 587.33, gain: 0.26, powerChord: false }, // D5 chime win: [523.25, 659.25, 783.99, 1046.5], // C E G C — bright arpeggio }, shred: { particles: ['🔥', '💥', '⚡', '💀'], sound: { wave: 'sawtooth', move: 98.0, gain: 0.2, powerChord: true }, // G2 chug win: [82.41, 110.0, 164.81, 220.0], // E A E A — power-chord climb }, }; /* ---------- Stages (global vibe) ---------- */ const STAGES = { sparkle: { startLabel: 'Start Playing!', winnerTitle: '🎉 Winner! 🎉', winnerSub: 'A magical victory!', drawText: "It's a Draw! 🤝", celebrate: ['🎉', '✨', '🌟', '💫', '🎊', '⭐', '🌈', '🦄'], }, shred: { startLabel: 'Start Battle!', winnerTitle: '🤘 WINNER 🤘', winnerSub: 'TOTAL DOMINATION!', drawText: 'STALEMATE! ⚔️', celebrate: ['🔥', '💀', '⚡', '🤘', '💥', '🎸', '☠️', '🐉'], }, mixed: { startLabel: 'Start Battle!', winnerTitle: '🏆 WINNER 🏆', winnerSub: 'Champion of the arena!', drawText: 'DRAW! 🤝', celebrate: ['🎉', '🔥', '✨', '⚡', '🌟', '💥', '🌈', '💀', '🦄', '🤘'], }, }; /* ---------- Persistence ---------- */ const STORE_KEY = 'ttt_state_v2'; function loadState() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) || {}; } catch (_) { return {}; } } function saveState() { try { localStorage.setItem(STORE_KEY, JSON.stringify({ stage: currentStage, p1Name: player1Input.value, p2Name: player2Input.value, p1Avatar: player1Avatar, p2Avatar: player2Avatar, scores: { p1: player1Score, p2: player2Score }, soundOn, })); } catch (_) { /* storage unavailable */ } } const persisted = loadState(); /* ---------- Game state ---------- */ let currentStage = STAGES[persisted.stage] ? persisted.stage : 'mixed'; let currentPlayer = 'X'; let gameBoard = ['', '', '', '', '', '', '', '', '']; let gameActive = true; let player1Name = ''; let player2Name = ''; let player1Avatar = AVATARS.some((a) => a.emoji === persisted.p1Avatar) ? persisted.p1Avatar : '⭐'; let player2Avatar = AVATARS.some((a) => a.emoji === persisted.p2Avatar) ? persisted.p2Avatar : '💀'; let player1Score = persisted.scores ? persisted.scores.p1 : 0; let player2Score = persisted.scores ? persisted.scores.p2 : 0; let soundOn = persisted.soundOn !== false; /* ---------- DOM ---------- */ const root = document.documentElement; const nameScreen = document.getElementById('nameScreen'); const gameScreen = document.getElementById('gameScreen'); const winnerScreen = document.getElementById('winnerScreen'); const player1Input = document.getElementById('player1Name'); const player2Input = document.getElementById('player2Name'); const startBtn = document.getElementById('startBtn'); const startLabel = startBtn.querySelector('.btn-label'); const player1Display = document.getElementById('player1Display'); const player2Display = document.getElementById('player2Display'); const player1DisplayBox = document.querySelector('.player1-display'); const player2DisplayBox = document.querySelector('.player2-display'); const gameStatus = document.getElementById('gameStatus'); const gameBoardElement = document.getElementById('gameBoard'); const cells = Array.from(document.querySelectorAll('[data-cell]')); const resetBtn = document.getElementById('resetBtn'); const changeNamesBtn = document.getElementById('changeNamesBtn'); const changeNamesBtn2 = document.getElementById('changeNamesBtn2'); const playAgainBtn = document.getElementById('playAgainBtn'); const resetScoresBtn = document.getElementById('resetScoresBtn'); const muteBtn = document.getElementById('muteBtn'); const winnerName = document.getElementById('winnerName'); const winnerAvatar = document.getElementById('winnerAvatar'); const winnerTitle = document.getElementById('winnerTitle'); const winnerSub = document.getElementById('winnerSub'); const winnerCard = winnerScreen.querySelector('.winner-card'); const celebration = document.getElementById('celebration'); const player1AvatarOptions = document.getElementById('player1Avatars'); const player2AvatarOptions = document.getElementById('player2Avatars'); const score1Element = document.getElementById('score1'); const score2Element = document.getElementById('score2'); const score1Name = document.getElementById('score1Name'); const score2Name = document.getElementById('score2Name'); const score1Avatar = document.getElementById('score1Avatar'); const score2Avatar = document.getElementById('score2Avatar'); const score1Item = document.querySelector('.player1-score'); const score2Item = document.querySelector('.player2-score'); const player1SymbolElement = document.getElementById('player1Symbol'); const player2SymbolElement = document.getElementById('player2Symbol'); const stageOptions = Array.from(document.querySelectorAll('.stage-option')); const winningCombinations = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; /* ---------- Audio ---------- */ let audioCtx = null; function getAudioCtx() { if (!audioCtx) { try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (_) { audioCtx = null; } } return audioCtx; } function tone(freq, startOffset, duration, wave, gain) { const ctx = getAudioCtx(); if (!ctx) return; const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.connect(g); g.connect(ctx.destination); osc.type = wave; osc.frequency.value = freq; const t0 = ctx.currentTime + startOffset; const t1 = t0 + duration; g.gain.setValueAtTime(gain, t0); g.gain.exponentialRampToValueAtTime(0.001, t1); osc.start(t0); osc.stop(t1); } function playMoveSound(camp) { if (!soundOn) return; const s = CAMPS[camp].sound; tone(s.move, 0, s.powerChord ? 0.18 : 0.1, s.wave, s.gain); if (s.powerChord) tone(s.move * 1.5, 0, 0.18, s.wave, s.gain * 0.6); // fifth } function playWinSound(camp) { if (!soundOn) return; const c = CAMPS[camp]; const step = camp === 'shred' ? 0.12 : 0.15; c.win.forEach((freq, i) => { tone(freq, i * step, 0.3, c.sound.wave, c.sound.gain); if (c.sound.powerChord) tone(freq * 1.5, i * step, 0.3, c.sound.wave, c.sound.gain * 0.5); }); } function playDrawSound() { if (!soundOn) return; [440, 392, 349.23].forEach((f, i) => tone(f, i * 0.16, 0.25, 'sine', 0.16)); } /* ---------- Setup rendering ---------- */ function renderAvatarGrid(container, selectedAvatar) { container.innerHTML = ''; AVATARS.forEach(({ emoji, camp }) => { const opt = document.createElement('button'); opt.type = 'button'; const selected = emoji === selectedAvatar; opt.className = `avatar-option camp-${camp}${selected ? ' selected' : ''}`; opt.dataset.avatar = emoji; opt.setAttribute('role', 'radio'); opt.setAttribute('aria-checked', selected ? 'true' : 'false'); opt.setAttribute('aria-label', `Avatar ${emoji} (${camp})`); opt.textContent = emoji; container.appendChild(opt); }); } function applyStage(stage, { persist = true } = {}) { currentStage = stage; root.setAttribute('data-stage', stage); stageOptions.forEach((opt) => opt.setAttribute('aria-checked', opt.dataset.stage === stage ? 'true' : 'false')); startLabel.textContent = STAGES[stage].startLabel; if (persist) saveState(); scheduleFit(); // font swap (e.g. Metal Mania) changes height → refit } /* ---------- Setup interactions ---------- */ stageOptions.forEach((opt) => { opt.addEventListener('click', () => applyStage(opt.dataset.stage)); }); function wireAvatarGroup(container, isP1) { container.addEventListener('click', (e) => { const opt = e.target.closest('.avatar-option'); if (!opt) return; const picked = opt.dataset.avatar; const other = isP1 ? player2Avatar : player1Avatar; if (picked === other) { // Both players can't share the exact same avatar — nudge, don't select. opt.animate( [{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(6px)' }, { transform: 'translateX(0)' }], { duration: 250 } ); return; } container.querySelectorAll('.avatar-option').forEach((o) => { o.classList.remove('selected'); o.setAttribute('aria-checked', 'false'); }); opt.classList.add('selected'); opt.setAttribute('aria-checked', 'true'); if (isP1) player1Avatar = picked; else player2Avatar = picked; saveState(); }); } wireAvatarGroup(player1AvatarOptions, true); wireAvatarGroup(player2AvatarOptions, false); startBtn.addEventListener('click', startGame); resetBtn.addEventListener('click', resetGame); playAgainBtn.addEventListener('click', resetGame); changeNamesBtn.addEventListener('click', () => showScreen(nameScreen)); changeNamesBtn2.addEventListener('click', () => showScreen(nameScreen)); resetScoresBtn.addEventListener('click', () => { player1Score = 0; player2Score = 0; score1Element.textContent = '0'; score2Element.textContent = '0'; saveState(); }); player1Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') player2Input.focus(); }); player2Input.addEventListener('keypress', (e) => { if (e.key === 'Enter') startGame(); }); player1Input.addEventListener('input', saveState); player2Input.addEventListener('input', saveState); muteBtn.addEventListener('click', () => { soundOn = !soundOn; muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true'); if (soundOn) { getAudioCtx(); playMoveSound(campOf(player1Avatar)); } saveState(); }); /* ---------- Board interactions (mouse + keyboard) ---------- */ cells.forEach((cell) => { cell.addEventListener('click', handleCellClick); cell.addEventListener('keydown', handleCellKeydown); }); function handleCellKeydown(e) { const idx = Number(e.currentTarget.dataset.index); let target = null; switch (e.key) { case 'ArrowRight': target = idx % 3 === 2 ? idx - 2 : idx + 1; break; case 'ArrowLeft': target = idx % 3 === 0 ? idx + 2 : idx - 1; break; case 'ArrowDown': target = (idx + 3) % 9; break; case 'ArrowUp': target = (idx + 6) % 9; break; default: return; } e.preventDefault(); cells[target].focus(); } function handleCellClick(e) { const cell = e.currentTarget; const index = Number(cell.dataset.index); if (gameBoard[index] !== '' || !gameActive) return; getAudioCtx(); makeMove(cell, index); checkResult(); } function activeAvatar() { return currentPlayer === 'X' ? player1Avatar : player2Avatar; } function activeName() { return currentPlayer === 'X' ? player1Name : player2Name; } function makeMove(cell, index) { gameBoard[index] = currentPlayer; const avatar = activeAvatar(); const camp = campOf(avatar); cell.textContent = avatar; cell.classList.add('filled', `camp-${camp}`); cell.setAttribute('aria-label', `Cell ${index + 1}, ${activeName()}`); createParticleBurst(cell, camp, avatar); playMoveSound(camp); if (camp === 'shred') { gameBoardElement.classList.remove('shake'); void gameBoardElement.offsetWidth; gameBoardElement.classList.add('shake'); } } function checkResult() { let winningCombo = null; for (const combo of winningCombinations) { const [a, b, c] = combo; if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) { winningCombo = combo; break; } } if (winningCombo) { gameActive = false; const winnerIsP1 = currentPlayer === 'X'; const camp = campOf(winnerIsP1 ? player1Avatar : player2Avatar); winningCombo.forEach((i) => { cells[i].classList.remove('camp-sparkle', 'camp-shred'); cells[i].classList.add('winning', `camp-${camp}`); }); const name = winnerIsP1 ? player1Name : player2Name; const avatar = winnerIsP1 ? player1Avatar : player2Avatar; if (winnerIsP1) { player1Score++; bumpScore(score1Element, player1Score); } else { player2Score++; bumpScore(score2Element, player2Score); } saveState(); gameStatus.textContent = `${name} wins!`; setTimeout(() => { const stage = STAGES[currentStage]; winnerTitle.textContent = stage.winnerTitle; winnerName.textContent = name; winnerAvatar.textContent = avatar; winnerSub.textContent = stage.winnerSub; winnerCard.classList.remove('camp-sparkle', 'camp-shred'); winnerCard.classList.add(`camp-${camp}`); renderCelebration(camp); showScreen(winnerScreen); playWinSound(camp); }, 1100); return; } if (!gameBoard.includes('')) { gameActive = false; gameStatus.textContent = STAGES[currentStage].drawText; gameStatus.classList.add('draw'); playDrawSound(); return; } currentPlayer = currentPlayer === 'X' ? 'O' : 'X'; updateStatus(); updatePlayerHighlight(); } function bumpScore(el, value) { el.textContent = value; el.classList.add('score-update'); setTimeout(() => el.classList.remove('score-update'), 500); } function updateStatus() { gameStatus.classList.remove('draw'); gameStatus.textContent = `${activeName()}'s Turn`; } function updatePlayerHighlight() { player1DisplayBox.classList.toggle('active', currentPlayer === 'X'); player2DisplayBox.classList.toggle('active', currentPlayer === 'O'); } /* ---------- Apply per-player camps to game UI ---------- */ function applyPlayerCamps() { const c1 = campOf(player1Avatar); const c2 = campOf(player2Avatar); player1DisplayBox.classList.remove('camp-sparkle', 'camp-shred'); player2DisplayBox.classList.remove('camp-sparkle', 'camp-shred'); score1Item.classList.remove('camp-sparkle', 'camp-shred'); score2Item.classList.remove('camp-sparkle', 'camp-shred'); player1DisplayBox.classList.add(`camp-${c1}`); player2DisplayBox.classList.add(`camp-${c2}`); score1Item.classList.add(`camp-${c1}`); score2Item.classList.add(`camp-${c2}`); } /* ---------- Screens ---------- */ function showScreen(screen) { [nameScreen, gameScreen, winnerScreen].forEach((s) => s.classList.remove('active')); screen.classList.add('active'); scheduleFit(); } /* ---------- Fit-to-viewport ---------- Scale the active card (via the --fit custom prop) so the whole screen always fits — it's tic-tac-toe, no scrolling, ever. */ let fitRAF = 0; function scheduleFit() { if (fitRAF) cancelAnimationFrame(fitRAF); fitRAF = requestAnimationFrame(() => { fitRAF = 0; fitActiveScreen(); }); } function fitActiveScreen() { const screen = document.querySelector('.screen.active'); if (!screen) return; const card = screen.querySelector('.card'); if (!card) return; // offsetWidth/Height ignore transforms, so we always read the natural size. const w = card.offsetWidth; const h = card.offsetHeight; if (!w || !h) return; const margin = 16; // small breathing room around the card const scale = Math.min(1, (window.innerWidth - margin) / w, (window.innerHeight - margin) / h); card.style.setProperty('--fit', String(scale)); } window.addEventListener('resize', scheduleFit); window.addEventListener('orientationchange', scheduleFit); if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleFit); /* ---------- Lifecycle ---------- */ function startGame() { player1Name = player1Input.value.trim() || 'Player 1'; player2Name = player2Input.value.trim() || 'Player 2'; player1Display.textContent = player1Name; player2Display.textContent = player2Name; player1SymbolElement.textContent = player1Avatar; player2SymbolElement.textContent = player2Avatar; score1Name.textContent = player1Name; score2Name.textContent = player2Name; score1Avatar.textContent = player1Avatar; score2Avatar.textContent = player2Avatar; score1Element.textContent = player1Score; score2Element.textContent = player2Score; applyPlayerCamps(); saveState(); resetBoardState(); showScreen(gameScreen); } function resetBoardState() { currentPlayer = Math.random() < 0.5 ? 'X' : 'O'; gameBoard = ['', '', '', '', '', '', '', '', '']; gameActive = true; cells.forEach((cell, i) => { cell.textContent = ''; cell.classList.remove('winning', 'filled', 'camp-sparkle', 'camp-shred'); cell.setAttribute('aria-label', `Cell ${i + 1}, empty`); }); gameStatus.classList.remove('draw'); updateStatus(); updatePlayerHighlight(); } function resetGame() { resetBoardState(); showScreen(gameScreen); } /* ---------- Effects ---------- */ function createParticleBurst(cell, camp, emoji) { const rect = cell.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const set = CAMPS[camp].particles.concat(emoji); const count = 8; for (let i = 0; i < count; i++) { const p = document.createElement('div'); p.className = 'particle'; p.textContent = set[Math.floor(Math.random() * set.length)]; const angle = (Math.PI * 2 * i) / count; const dist = 80 + Math.random() * 40; p.style.left = cx + 'px'; p.style.top = cy + 'px'; p.style.setProperty('--tx', Math.cos(angle) * dist + 'px'); p.style.setProperty('--ty', Math.sin(angle) * dist + 'px'); document.body.appendChild(p); setTimeout(() => p.remove(), 800); } } function renderCelebration(winnerCamp) { celebration.innerHTML = ''; // Blend the stage's celebration set with the winner's camp particles. const set = STAGES[currentStage].celebrate.concat(CAMPS[winnerCamp].particles); const count = 30; for (let i = 0; i < count; i++) { const piece = document.createElement('span'); piece.className = 'confetti-piece'; piece.textContent = set[Math.floor(Math.random() * set.length)]; piece.style.left = Math.random() * 100 + '%'; piece.style.animationDuration = 2.5 + Math.random() * 2.5 + 's'; piece.style.animationDelay = Math.random() * 2 + 's'; piece.style.fontSize = 1.2 + Math.random() * 1.4 + 'em'; celebration.appendChild(piece); } } /* ---------- Init ---------- */ function init() { if (persisted.p1Name) player1Input.value = persisted.p1Name; if (persisted.p2Name) player2Input.value = persisted.p2Name; muteBtn.setAttribute('aria-pressed', soundOn ? 'false' : 'true'); score1Element.textContent = player1Score; score2Element.textContent = player2Score; renderAvatarGrid(player1AvatarOptions, player1Avatar); renderAvatarGrid(player2AvatarOptions, player2Avatar); applyStage(currentStage, { persist: false }); applyPlayerCamps(); updatePlayerHighlight(); scheduleFit(); } init();