-
-
-
๐ Winner! ๐
+
+
+
Winner!
๐
-
-
-
-
+
+
+
+
-
-
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..68b99f2
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,27 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ gzip on;
+ gzip_types text/css application/javascript image/svg+xml;
+ gzip_min_length 256;
+
+ # Static assets: cache for a week (filenames are stable for this app).
+ location ~* \.(css|js|svg|png|ico)$ {
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
+ add_header Cache-Control "public, max-age=604800";
+ }
+
+ # HTML entry point: always revalidate so redeploys show up immediately.
+ location / {
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
+ add_header Cache-Control "no-cache" always;
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/script.js b/script.js
index e8e4809..749c29f 100644
--- a/script.js
+++ b/script.js
@@ -1,15 +1,112 @@
-// Game State
+/* =========================================================================
+ 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 = 'โญ';
-let player2Avatar = '๐ธ';
-let player1Score = 0;
-let player2Score = 0;
+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 Elements
+/* ---------- DOM ---------- */
+const root = document.documentElement;
const nameScreen = document.getElementById('nameScreen');
const gameScreen = document.getElementById('gameScreen');
const winnerScreen = document.getElementById('winnerScreen');
@@ -17,112 +114,329 @@ 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 = document.querySelectorAll('[data-cell]');
+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');
-// Avatar elements
const player1AvatarOptions = document.getElementById('player1Avatars');
const player2AvatarOptions = document.getElementById('player2Avatars');
-// Score elements
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');
-// Symbol elements
const player1SymbolElement = document.getElementById('player1Symbol');
const player2SymbolElement = document.getElementById('player2Symbol');
-// Winning Combinations
+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]
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
+ [0, 4, 8], [2, 4, 6],
];
-// Event Listeners
+/* ---------- 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();
+}
+
+/* ---------- 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);
-changeNamesBtn.addEventListener('click', showNameScreen);
-changeNamesBtn2.addEventListener('click', showNameScreen);
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();
+});
-cells.forEach(cell => {
+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();
+}
-// Avatar selection listeners
-player1AvatarOptions.addEventListener('click', (e) => {
- if (e.target.classList.contains('avatar-option')) {
- player1AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => {
- opt.classList.remove('selected');
+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}`);
});
- e.target.classList.add('selected');
- player1Avatar = e.target.dataset.avatar;
- }
-});
-player2AvatarOptions.addEventListener('click', (e) => {
- if (e.target.classList.contains('avatar-option')) {
- player2AvatarOptions.querySelectorAll('.avatar-option').forEach(opt => {
- opt.classList.remove('selected');
- });
- e.target.classList.add('selected');
- player2Avatar = e.target.dataset.avatar;
- }
-});
+ const name = winnerIsP1 ? player1Name : player2Name;
+ const avatar = winnerIsP1 ? player1Avatar : player2Avatar;
+ if (winnerIsP1) { player1Score++; bumpScore(score1Element, player1Score); }
+ else { player2Score++; bumpScore(score2Element, player2Score); }
+ saveState();
-// Allow Enter key to start game
-player1Input.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- player2Input.focus();
+ 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;
}
-});
-player2Input.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') {
- startGame();
+ if (!gameBoard.includes('')) {
+ gameActive = false;
+ gameStatus.textContent = STAGES[currentStage].drawText;
+ gameStatus.classList.add('draw');
+ playDrawSound();
+ return;
}
-});
-// Functions
+ 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');
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+}
+
+/* ---------- Lifecycle ---------- */
function startGame() {
- // Get player names or use defaults
player1Name = player1Input.value.trim() || 'Player 1';
player2Name = player2Input.value.trim() || 'Player 2';
- // Randomly choose starting player
- currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
-
- // Update displays
player1Display.textContent = player1Name;
player2Display.textContent = player2Name;
-
- // Update symbols with avatars
player1SymbolElement.textContent = player1Avatar;
player2SymbolElement.textContent = player2Avatar;
- // Update score board
score1Name.textContent = player1Name;
score2Name.textContent = player2Name;
score1Avatar.textContent = player1Avatar;
@@ -130,257 +444,81 @@ function startGame() {
score1Element.textContent = player1Score;
score2Element.textContent = player2Score;
- // Switch to game screen
- showGameScreen();
- updateStatus();
- updatePlayerHighlight();
+ applyPlayerCamps();
+ saveState();
+ resetBoardState();
+ showScreen(gameScreen);
}
-function showNameScreen() {
- nameScreen.classList.add('active');
- gameScreen.classList.remove('active');
- winnerScreen.classList.remove('active');
-}
-
-function showGameScreen() {
- nameScreen.classList.remove('active');
- gameScreen.classList.add('active');
- winnerScreen.classList.remove('active');
-}
-
-function showWinnerScreen() {
- nameScreen.classList.remove('active');
- gameScreen.classList.remove('active');
- winnerScreen.classList.add('active');
-}
-
-function handleCellClick(e) {
- const cell = e.target;
- const cellIndex = Array.from(cells).indexOf(cell);
-
- // Check if cell is already filled or game is not active
- if (gameBoard[cellIndex] !== '' || !gameActive) {
- return;
- }
-
- // Make move
- makeMove(cell, cellIndex);
-
- // Check for win or draw
- checkResult();
-}
-
-function makeMove(cell, index) {
- gameBoard[index] = currentPlayer;
-
- // Use avatar instead of X/O
- const avatar = currentPlayer === 'X' ? player1Avatar : player2Avatar;
- cell.textContent = avatar;
- cell.classList.add(currentPlayer.toLowerCase());
-
- // Create particle burst effect
- createParticleBurst(cell, avatar);
-
- // Play move sound
- playMoveSound(currentPlayer);
-}
-
-function checkResult() {
- let roundWon = false;
- let winningCombo = null;
-
- // Check all winning combinations
- for (let i = 0; i < winningCombinations.length; i++) {
- const [a, b, c] = winningCombinations[i];
- if (gameBoard[a] && gameBoard[a] === gameBoard[b] && gameBoard[a] === gameBoard[c]) {
- roundWon = true;
- winningCombo = [a, b, c];
- break;
- }
- }
-
- if (roundWon) {
- // Highlight winning cells
- winningCombo.forEach(index => {
- cells[index].classList.add('winning');
- });
-
- // Determine winner
- const winner = currentPlayer === 'X' ? player1Name : player2Name;
- const winnerAvatarSymbol = currentPlayer === 'X' ? player1Avatar : player2Avatar;
- gameActive = false;
-
- // Update score
- if (currentPlayer === 'X') {
- player1Score++;
- score1Element.textContent = player1Score;
- score1Element.classList.add('score-update');
- setTimeout(() => score1Element.classList.remove('score-update'), 500);
- } else {
- player2Score++;
- score2Element.textContent = player2Score;
- score2Element.classList.add('score-update');
- setTimeout(() => score2Element.classList.remove('score-update'), 500);
- }
-
- // Show winner screen after animation
- setTimeout(() => {
- winnerName.textContent = winner;
- winnerAvatar.textContent = winnerAvatarSymbol;
- showWinnerScreen();
- playWinSound();
- }, 1000);
-
- return;
- }
-
- // Check for draw
- if (!gameBoard.includes('')) {
- gameActive = false;
- gameStatus.textContent = "It's a Draw! ๐ค";
- setTimeout(() => {
- resetGame();
- }, 2000);
- return;
- }
-
- // Switch player
- currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
- updateStatus();
- updatePlayerHighlight();
-}
-
-function updateStatus() {
- const currentPlayerName = currentPlayer === 'X' ? player1Name : player2Name;
- gameStatus.textContent = `${currentPlayerName}'s Turn (${currentPlayer})`;
-}
-
-function updatePlayerHighlight() {
- const player1Div = document.querySelector('.player1-display');
- const player2Div = document.querySelector('.player2-display');
-
- if (currentPlayer === 'X') {
- player1Div.classList.add('active');
- player2Div.classList.remove('active');
- } else {
- player1Div.classList.remove('active');
- player2Div.classList.add('active');
- }
-}
-
-function resetGame() {
- // Reset game state
+function resetBoardState() {
currentPlayer = Math.random() < 0.5 ? 'X' : 'O';
gameBoard = ['', '', '', '', '', '', '', '', ''];
gameActive = true;
-
- // Clear cells
- cells.forEach(cell => {
+ cells.forEach((cell, i) => {
cell.textContent = '';
- cell.classList.remove('x', 'o', 'winning');
+ cell.classList.remove('winning', 'filled', 'camp-sparkle', 'camp-shred');
+ cell.setAttribute('aria-label', `Cell ${i + 1}, empty`);
});
-
- // Update display
+ gameStatus.classList.remove('draw');
updateStatus();
updatePlayerHighlight();
-
- // Show game screen
- showGameScreen();
}
-function playWinSound() {
- // Create a simple celebratory sound using Web Audio API
- try {
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
-
- // Play a series of ascending notes
- const notes = [523.25, 587.33, 659.25, 783.99]; // C, D, E, G
-
- notes.forEach((frequency, index) => {
- const oscillator = audioContext.createOscillator();
- const gainNode = audioContext.createGain();
-
- oscillator.connect(gainNode);
- gainNode.connect(audioContext.destination);
-
- oscillator.frequency.value = frequency;
- oscillator.type = 'sine';
-
- const startTime = audioContext.currentTime + (index * 0.15);
- const stopTime = startTime + 0.3;
-
- gainNode.gain.setValueAtTime(0.3, startTime);
- gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
-
- oscillator.start(startTime);
- oscillator.stop(stopTime);
- });
- } catch (error) {
- console.log('Audio not supported');
- }
+function resetGame() {
+ resetBoardState();
+ showScreen(gameScreen);
}
-function playMoveSound(player) {
- // Create different sound for each player
- try {
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
- const oscillator = audioContext.createOscillator();
- const gainNode = audioContext.createGain();
-
- oscillator.connect(gainNode);
- gainNode.connect(audioContext.destination);
-
- // Different frequencies for different players
- oscillator.frequency.value = player === 'X' ? 523.25 : 659.25; // C or E
- oscillator.type = 'sine';
-
- const startTime = audioContext.currentTime;
- const stopTime = startTime + 0.1;
-
- gainNode.gain.setValueAtTime(0.2, startTime);
- gainNode.gain.exponentialRampToValueAtTime(0.01, stopTime);
-
- oscillator.start(startTime);
- oscillator.stop(stopTime);
- } catch (error) {
- console.log('Audio not supported');
- }
-}
-
-function createParticleBurst(cell, emoji) {
- // Get cell position
+/* ---------- Effects ---------- */
+function createParticleBurst(cell, camp, emoji) {
const rect = cell.getBoundingClientRect();
- const centerX = rect.left + rect.width / 2;
- const centerY = rect.top + rect.height / 2;
-
- // Create particles
- const particleCount = 8;
- const particles = ['โจ', '๐ซ', 'โญ', '๐', emoji];
-
- for (let i = 0; i < particleCount; i++) {
- const particle = document.createElement('div');
- particle.className = 'particle';
- particle.textContent = particles[Math.floor(Math.random() * particles.length)];
-
- // Random direction
- const angle = (Math.PI * 2 * i) / particleCount;
- const distance = 80 + Math.random() * 40;
- const tx = Math.cos(angle) * distance;
- const ty = Math.sin(angle) * distance;
-
- particle.style.left = centerX + 'px';
- particle.style.top = centerY + 'px';
- particle.style.setProperty('--tx', tx + 'px');
- particle.style.setProperty('--ty', ty + 'px');
-
- document.body.appendChild(particle);
-
- // Remove particle after animation
- setTimeout(() => {
- particle.remove();
- }, 800);
+ 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);
}
}
-// Initialize player highlight
-updatePlayerHighlight();
+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();
+}
+init();
diff --git a/style.css b/style.css
index 65505d9..d954bbd 100644
--- a/style.css
+++ b/style.css
@@ -1,22 +1,129 @@
-* {
+/* =========================================================================
+ ULTIMATE TIC-TAC-TOE โ per-player camps + switchable stages
+ ------------------------------------------------------------------------
+ Two orthogonal systems:
+ 1. STAGE (global vibe) โ [data-stage="sparkle|shred|mixed"] on
+ Controls background, backdrop FX, fonts, surface (light/dark), winner.
+ 2. CAMP (per player) โ .camp-sparkle / .camp-shred on player elements
+ Controls a player's accent color + glow on their display, score, and
+ their pieces on the board. A player's camp comes from their avatar, so
+ one player can be Sparkle (๐ฆ) while the other is Shred (๐).
+ ========================================================================= */
+
+*,
+*::before,
+*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
+:root {
+ --radius-lg: 28px;
+ --radius-md: 16px;
+ --radius-sm: 12px;
+ --maxw: 940px;
+ --transition: 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+
+ /* Per-camp identity colors โ constant across all stages so a player's
+ flavor travels with their avatar. */
+ --sparkle-color: #f368e0;
+ --sparkle-glow: rgba(243, 104, 224, 0.55);
+ --sparkle-tint: rgba(243, 104, 224, 0.14);
+ --shred-color: #ff2d2d;
+ --shred-glow: rgba(255, 45, 45, 0.6);
+ --shred-tint: rgba(255, 45, 45, 0.13);
+}
+
+/* ---------- Stage themes ---------- */
+[data-stage="sparkle"] {
+ --bg: linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%);
+ --bg-size: 400% 400%;
+ --surface: rgba(255, 255, 255, 0.95);
+ --surface-2: rgba(255, 255, 255, 0.55);
+ --surface-border: rgba(255, 255, 255, 0.85);
+ --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 40px rgba(246, 79, 254, 0.5), inset 0 0 60px rgba(255, 255, 255, 0.5);
+ --accent1: #f093fb;
+ --accent2: #4facfe;
+ --accent-hot: #f5576c;
+ --text: #5a3d7a;
+ --text-strong: #764ba2;
+ --text-muted: #9b86b3;
+ --heading-font: 'Fredoka', 'Segoe UI', sans-serif;
+ --body-font: 'Fredoka', 'Segoe UI', Tahoma, sans-serif;
+ --heading-spacing: 0px;
+ --heading-transform: none;
+ --cell-bg: #ffffff;
+ --board-bg: linear-gradient(135deg, rgba(240, 147, 251, 0.2), rgba(79, 172, 254, 0.2));
+ --input-bg: rgba(255, 255, 255, 0.9);
+ --title-gradient: linear-gradient(45deg, #f093fb, #f5576c, #4facfe, #f093fb);
+}
+
+[data-stage="shred"] {
+ --bg: linear-gradient(135deg, #0a0a0c 0%, #1a0205 30%, #2b0508 50%, #160003 75%, #0a0a0c 100%);
+ --bg-size: 300% 300%;
+ --surface: rgba(18, 16, 18, 0.94);
+ --surface-2: rgba(255, 255, 255, 0.06);
+ --surface-border: rgba(255, 45, 45, 0.55);
+ --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.75), 0 0 45px rgba(255, 26, 26, 0.45), inset 0 0 70px rgba(255, 26, 26, 0.06);
+ --accent1: #ff1a1a;
+ --accent2: #ff6a00;
+ --accent-hot: #ff2d2d;
+ --text: #ededed;
+ --text-strong: #ffffff;
+ --text-muted: #9a9aa0;
+ --heading-font: 'Metal Mania', 'Bebas Neue', 'Arial Black', Impact, sans-serif;
+ --body-font: 'Bebas Neue', 'Arial Narrow', 'Segoe UI', sans-serif;
+ --heading-spacing: 2px;
+ --heading-transform: uppercase;
+ --cell-bg: #161417;
+ --board-bg: linear-gradient(135deg, rgba(255, 26, 26, 0.12), rgba(0, 0, 0, 0.4));
+ --input-bg: rgba(0, 0, 0, 0.45);
+ --title-gradient: linear-gradient(45deg, #ff1a1a, #ff6a00, #ffd000, #ff1a1a);
+}
+
+/* Mixed: a neutral battleground where BOTH camps' colors pop. */
+[data-stage="mixed"] {
+ --bg: linear-gradient(135deg, #1a0b2e 0%, #2b0a1e 30%, #1d1033 55%, #2a0815 78%, #140a24 100%);
+ --bg-size: 320% 320%;
+ --surface: rgba(24, 18, 34, 0.93);
+ --surface-2: rgba(255, 255, 255, 0.07);
+ --surface-border: rgba(243, 104, 224, 0.4);
+ --card-shadow: 0 20px 60px rgba(0, 0, 0, 0.7), 0 0 45px rgba(243, 104, 224, 0.3), 0 0 60px rgba(255, 45, 45, 0.18), inset 0 0 70px rgba(255, 255, 255, 0.04);
+ --accent1: #f368e0;
+ --accent2: #ff2d2d;
+ --accent-hot: #ff6a00;
+ --text: #f0e9f5;
+ --text-strong: #ffffff;
+ --text-muted: #b9a9c9;
+ --heading-font: 'Bebas Neue', 'Fredoka', sans-serif;
+ --body-font: 'Fredoka', 'Segoe UI', sans-serif;
+ --heading-spacing: 1px;
+ --heading-transform: uppercase;
+ --cell-bg: #1c1428;
+ --board-bg: linear-gradient(135deg, rgba(243, 104, 224, 0.14), rgba(255, 45, 45, 0.12));
+ --input-bg: rgba(0, 0, 0, 0.35);
+ --title-gradient: linear-gradient(45deg, #f368e0, #b06bff, #ff2d2d, #ff6a00, #f368e0);
+}
+
+/* ---------- Camp accents (per player) ---------- */
+.camp-sparkle { --pc: var(--sparkle-color); --pg: var(--sparkle-glow); --pt: var(--sparkle-tint); }
+.camp-shred { --pc: var(--shred-color); --pg: var(--shred-glow); --pt: var(--shred-tint); }
+
+/* ---------- Base layout (scroll-safe centering) ---------- */
body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%);
- background-size: 400% 400%;
+ font-family: var(--body-font);
+ background: var(--bg);
+ background-size: var(--bg-size);
animation: gradientShift 15s ease infinite;
min-height: 100vh;
display: flex;
- justify-content: center;
- align-items: center;
+ flex-direction: column;
overflow-x: hidden;
- overflow-y: auto;
position: relative;
- padding: 20px 0;
+ padding: 28px 16px;
+ color: var(--text);
+ transition: color 0.4s ease;
}
@keyframes gradientShift {
@@ -25,17 +132,21 @@ body {
100% { background-position: 0% 50%; }
}
-/* Animated Stars Background */
-.stars {
- position: fixed;
- top: 0;
- left: 0;
+.container {
+ margin: auto;
width: 100%;
- height: 100%;
- pointer-events: none;
- z-index: 0;
+ max-width: var(--maxw);
+ position: relative;
+ z-index: 1;
}
+/* ---------- Animated backdrops ---------- */
+.backdrop { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
+
+.stars { position: absolute; inset: 0; opacity: 0; transition: opacity 0.6s ease; }
+[data-stage="sparkle"] .stars { opacity: 1; }
+[data-stage="mixed"] .stars { opacity: 0.5; }
+
.stars::before,
.stars::after {
content: '';
@@ -48,96 +159,101 @@ body {
700px 150px white, 200px 400px white, 600px 450px white,
150px 50px white, 450px 250px white, 350px 500px white,
800px 400px white, 50px 300px white, 550px 50px white,
- 250px 150px rgba(255,255,255,0.5), 650px 350px rgba(255,255,255,0.5),
- 400px 100px rgba(255,255,255,0.5), 100px 450px rgba(255,255,255,0.5);
+ 900px 250px white, 1100px 400px white, 1300px 150px white,
+ 250px 150px rgba(255,255,255,0.6), 650px 350px rgba(255,255,255,0.6),
+ 400px 100px rgba(255,255,255,0.6), 100px 450px rgba(255,255,255,0.6),
+ 1000px 500px rgba(255,255,255,0.6), 1200px 300px rgba(255,255,255,0.6);
border-radius: 50%;
animation: twinkle 3s infinite;
}
-
-.stars::after {
- animation-delay: 1.5s;
-}
+.stars::after { animation-delay: 1.5s; left: 40px; top: 30px; }
@keyframes twinkle {
0%, 100% { opacity: 0.3; transform: scale(1); }
- 50% { opacity: 1; transform: scale(1.2); }
+ 50% { opacity: 1; transform: scale(1.3); }
}
-.container {
- width: 100%;
- max-width: 900px;
- padding: 20px;
- position: relative;
- z-index: 1;
+.embers { position: absolute; inset: 0; opacity: 0; transition: opacity 0.6s ease; }
+[data-stage="shred"] .embers { opacity: 1; }
+[data-stage="mixed"] .embers { opacity: 0.7; }
+
+.embers::before,
+.embers::after {
+ content: '';
+ position: absolute;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #ff6a00;
+ box-shadow:
+ 10vw 90vh 0 0 #ff3000, 25vw 95vh 0 1px #ff6a00, 40vw 88vh 0 0 #ff1a1a,
+ 55vw 96vh 0 1px #ff8c00, 70vw 92vh 0 0 #ff3000, 85vw 97vh 0 1px #ff6a00,
+ 18vw 93vh 0 0 #ffae00, 33vw 91vh 0 0 #ff1a1a, 48vw 98vh 0 1px #ff6a00,
+ 63vw 94vh 0 0 #ff3000, 78vw 90vh 0 1px #ff8c00, 92vw 95vh 0 0 #ff1a1a,
+ 5vw 97vh 0 0 #ffae00, 30vw 99vh 0 0 #ff3000, 60vw 89vh 0 1px #ff6a00;
+ animation: emberRise 6s linear infinite;
+}
+.embers::after { animation-delay: 3s; animation-duration: 8s; }
+
+@keyframes emberRise {
+ 0% { transform: translateY(0) scale(1); opacity: 0; }
+ 10% { opacity: 1; }
+ 90% { opacity: 0.8; }
+ 100% { transform: translateY(-100vh) scale(0.3); opacity: 0; }
}
-/* Screens */
-.screen {
- display: none;
- animation: fadeIn 0.5s ease;
+.lightning { position: absolute; inset: 0; background: #fff; opacity: 0; }
+[data-stage="shred"] .lightning,
+[data-stage="mixed"] .lightning { animation: lightningFlash 9s infinite; }
+
+@keyframes lightningFlash {
+ 0%, 92%, 100% { opacity: 0; }
+ 93% { opacity: 0.16; }
+ 94% { opacity: 0; }
+ 95% { opacity: 0.26; }
+ 96% { opacity: 0; }
}
-.screen.active {
- display: block;
-}
+/* ---------- Cards & screens ---------- */
+.screen { display: none; animation: fadeIn 0.5s ease; }
+.screen.active { display: block; }
@keyframes fadeIn {
- from { opacity: 0; transform: scale(0.9); }
- to { opacity: 1; transform: scale(1); }
+ from { opacity: 0; transform: scale(0.96) translateY(10px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
}
-/* Name Input Screen */
-.name-card {
- background: rgba(255, 255, 255, 0.95);
- border-radius: 30px;
- padding: 30px 30px;
- box-shadow:
- 0 20px 60px rgba(0, 0, 0, 0.3),
- 0 0 40px rgba(246, 79, 254, 0.5),
- inset 0 0 60px rgba(255, 255, 255, 0.5);
+.card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--card-shadow);
backdrop-filter: blur(10px);
- border: 3px solid rgba(255, 255, 255, 0.8);
- max-height: 95vh;
- overflow-y: auto;
+ border: 3px solid var(--surface-border);
+ transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
-/* Custom Scrollbar */
-.name-card::-webkit-scrollbar {
- width: 8px;
-}
+.name-card { padding: 30px 30px 34px; }
+.game-card { padding: 36px; }
+.winner-card { padding: 56px 40px; text-align: center; position: relative; overflow: hidden; }
-.name-card::-webkit-scrollbar-track {
- background: rgba(240, 147, 251, 0.1);
- border-radius: 10px;
-}
-
-.name-card::-webkit-scrollbar-thumb {
- background: linear-gradient(135deg, #f093fb, #4facfe);
- border-radius: 10px;
-}
-
-.name-card::-webkit-scrollbar-thumb:hover {
- background: linear-gradient(135deg, #f5576c, #f093fb);
-}
-
-@keyframes float {
- 0%, 100% { transform: translateY(0px); }
- 50% { transform: translateY(-10px); }
-}
-
-.title {
- font-size: 2.2em;
+/* ---------- Headings ---------- */
+.title, .game-title, .winner-title {
text-align: center;
- background: linear-gradient(45deg, #f093fb, #f5576c, #4facfe, #f093fb);
+ font-family: var(--heading-font);
+ font-weight: 700;
+ letter-spacing: var(--heading-spacing);
+ text-transform: var(--heading-transform);
+ background: var(--title-gradient);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradientText 3s ease infinite;
- margin-bottom: 15px;
- font-weight: bold;
- text-shadow: 0 0 30px rgba(245, 87, 108, 0.5);
+ line-height: 1.05;
}
+.title { font-size: 2.4em; margin-bottom: 14px; }
+.game-title { font-size: 2.6em; margin-bottom: 22px; }
+.winner-title { font-size: 3.2em; margin-bottom: 22px; }
@keyframes gradientText {
0%, 100% { background-position: 0% 50%; }
@@ -146,603 +262,454 @@ body {
.sparkle-divider {
height: 3px;
- background: linear-gradient(90deg, transparent, #f093fb, #4facfe, #f093fb, transparent);
- margin: 15px 0;
+ background: linear-gradient(90deg, transparent, var(--accent1), var(--accent2), var(--accent1), transparent);
+ margin: 14px 0 20px;
border-radius: 10px;
+ overflow: hidden;
+ position: relative;
+}
+.sparkle-divider::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent);
animation: sparkleMove 2s linear infinite;
}
-
@keyframes sparkleMove {
0% { transform: translateX(-100%); }
- 100% { transform: translateX(100%); }
+ 100% { transform: translateX(200%); }
}
-.name-input-group {
- margin-bottom: 20px;
-}
-
-.name-input-group label {
+/* ---------- Stage selector ---------- */
+.stage-selector { margin-bottom: 16px; }
+.stage-label, .avatar-label, .setup-hint {
display: block;
- margin-bottom: 10px;
- font-size: 1.2em;
- font-weight: bold;
- color: #764ba2;
+ text-align: center;
+ color: var(--text-muted);
+ font-weight: 700;
+ letter-spacing: var(--heading-spacing);
+}
+.stage-label { margin-bottom: 10px; font-size: 0.95em; }
+.setup-hint { margin-bottom: 18px; font-size: 0.92em; font-weight: 500; }
+.setup-hint strong { color: var(--text-strong); }
+
+.stage-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+.stage-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ padding: 14px 8px;
+ border-radius: var(--radius-md);
+ border: 3px solid transparent;
+ background: var(--surface-2);
+ color: var(--text);
+ cursor: pointer;
+ transition: transform var(--transition), border-color 0.25s, box-shadow 0.25s, background 0.25s;
+ font-family: var(--body-font);
+}
+.stage-emoji { font-size: 1.8em; line-height: 1; }
+.stage-name { font-size: 1.15em; font-weight: 700; color: var(--text-strong); letter-spacing: var(--heading-spacing); }
+.stage-desc { font-size: 0.78em; color: var(--text-muted); text-align: center; }
+.stage-option:hover { transform: translateY(-3px); border-color: var(--accent2); }
+.stage-option[aria-checked="true"] {
+ border-color: var(--accent1);
+ background: linear-gradient(135deg, color-mix(in srgb, var(--accent1) 24%, transparent), color-mix(in srgb, var(--accent2) 18%, transparent));
+ box-shadow: 0 0 22px color-mix(in srgb, var(--accent1) 45%, transparent);
+}
+
+/* ---------- Player setup ---------- */
+.players-setup { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
+
+.name-input-group { margin-bottom: 16px; }
+.name-input-group label {
display: flex;
align-items: center;
- gap: 10px;
-}
-
-.player-icon {
- font-size: 1.5em;
- animation: bounce 1s infinite;
-}
-
-@keyframes bounce {
- 0%, 100% { transform: translateY(0); }
- 50% { transform: translateY(-5px); }
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 1.15em;
+ font-weight: 700;
+ color: var(--text-strong);
+ letter-spacing: var(--heading-spacing);
}
+.player-icon { font-size: 1.4em; animation: bounce 1.4s infinite; }
+@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
.name-input-group input {
width: 100%;
- padding: 15px 20px;
- font-size: 1.1em;
- border: 3px solid #f093fb;
- border-radius: 15px;
+ padding: 13px 16px;
+ font-size: 1.05em;
+ font-family: var(--body-font);
+ border: 3px solid var(--accent1);
+ border-radius: var(--radius-sm);
outline: none;
transition: all 0.3s ease;
- background: rgba(255, 255, 255, 0.9);
- font-family: inherit;
+ background: var(--input-bg);
+ color: var(--text);
}
-
+.name-input-group input::placeholder { color: var(--text-muted); }
.name-input-group input:focus {
- border-color: #4facfe;
- box-shadow: 0 0 20px rgba(79, 172, 254, 0.5);
+ border-color: var(--accent2);
+ box-shadow: 0 0 18px color-mix(in srgb, var(--accent2) 45%, transparent);
transform: scale(1.02);
}
-/* Avatar Selection */
-.avatar-section {
- margin: 25px 0 15px 0;
-}
-
-.avatar-title {
- text-align: center;
- font-size: 1.3em;
- color: #764ba2;
- margin-bottom: 15px;
- font-weight: bold;
-}
-
-.avatar-selection {
- display: flex;
- flex-direction: column;
- gap: 15px;
-}
-
-.player-avatar-group label {
- display: block;
- font-size: 1em;
- font-weight: bold;
- color: #764ba2;
- margin-bottom: 8px;
- text-align: center;
-}
-
+.avatar-label { margin-bottom: 8px; font-size: 0.95em; }
.avatar-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
- gap: 10px;
+ gap: 8px;
}
-
.avatar-option {
aspect-ratio: 1;
- font-size: 1.8em;
+ font-size: 1.55em;
display: flex;
align-items: center;
justify-content: center;
- border: 2px solid #e0e0e0;
- border-radius: 12px;
+ border: 2px solid var(--surface-border);
+ border-radius: var(--radius-sm);
cursor: pointer;
- transition: all 0.3s ease;
- background: white;
- padding: 8px;
+ transition: transform var(--transition), border-color 0.25s, box-shadow 0.25s, background 0.25s;
+ background: var(--cell-bg);
+ color: var(--text);
+ position: relative;
}
-
+/* Camp tint so the two "teams" of avatars read at a glance */
+.avatar-option.camp-sparkle { background: linear-gradient(135deg, var(--sparkle-tint), var(--cell-bg)); }
+.avatar-option.camp-shred { background: linear-gradient(135deg, var(--shred-tint), var(--cell-bg)); }
.avatar-option:hover {
transform: scale(1.15);
- border-color: #4facfe;
- box-shadow: 0 0 15px rgba(79, 172, 254, 0.5);
+ border-color: var(--pc, var(--accent2));
+ box-shadow: 0 0 14px var(--pg, var(--sparkle-glow));
}
-
.avatar-option.selected {
- border-color: #f093fb;
- background: linear-gradient(135deg, rgba(240, 147, 251, 0.2), rgba(79, 172, 254, 0.2));
- box-shadow: 0 0 20px rgba(240, 147, 251, 0.6);
+ border-color: var(--pc);
+ box-shadow: 0 0 20px var(--pg);
transform: scale(1.1);
}
-
-.avatar-option.selected:hover {
- transform: scale(1.2);
+.avatar-option.selected::after {
+ content: 'โ';
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 18px;
+ height: 18px;
+ font-size: 11px;
+ line-height: 18px;
+ text-align: center;
+ border-radius: 50%;
+ background: var(--pc);
+ color: #fff;
+ font-weight: 700;
}
+.avatar-option.selected:hover { transform: scale(1.18); }
-.start-btn {
+/* ---------- Buttons ---------- */
+.btn {
+ font-family: var(--heading-font);
+ font-weight: 700;
+ letter-spacing: var(--heading-spacing);
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: transform var(--transition), box-shadow 0.25s, background 0.25s, color 0.25s;
+}
+.btn-primary {
width: 100%;
padding: 15px;
- font-size: 1.3em;
- font-weight: bold;
- border: none;
- border-radius: 20px;
- background: linear-gradient(135deg, #f093fb, #f5576c);
- color: white;
- cursor: pointer;
- transition: all 0.3s ease;
- box-shadow: 0 10px 30px rgba(245, 87, 108, 0.4);
- margin-top: 15px;
+ font-size: 1.35em;
+ background: linear-gradient(135deg, var(--accent1), var(--accent-hot));
+ color: #fff;
+ box-shadow: 0 10px 30px color-mix(in srgb, var(--accent1) 40%, transparent);
+ margin-top: 22px;
+ text-transform: uppercase;
}
+.btn-primary:hover { transform: translateY(-4px) scale(1.03); box-shadow: 0 16px 40px color-mix(in srgb, var(--accent1) 55%, transparent); }
+.btn-primary:active { transform: translateY(-1px) scale(1.01); }
-.start-btn:hover {
- transform: translateY(-5px) scale(1.05);
- box-shadow: 0 15px 40px rgba(245, 87, 108, 0.6);
-}
-
-.start-btn:active {
- transform: translateY(-2px) scale(1.02);
-}
-
-/* Game Screen */
-.game-card {
- background: rgba(255, 255, 255, 0.95);
- border-radius: 30px;
- padding: 40px;
- box-shadow:
- 0 20px 60px rgba(0, 0, 0, 0.3),
- 0 0 40px rgba(246, 79, 254, 0.5),
- inset 0 0 60px rgba(255, 255, 255, 0.5);
- backdrop-filter: blur(10px);
- border: 3px solid rgba(255, 255, 255, 0.8);
-}
-
-.game-title {
- font-size: 2.5em;
- text-align: center;
- background: linear-gradient(45deg, #f093fb, #f5576c, #4facfe);
- background-size: 200% 200%;
- -webkit-background-clip: text;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- animation: gradientText 3s ease infinite;
- margin-bottom: 20px;
- font-weight: bold;
-}
-
-/* Game Layout - Horizontal Split */
-.game-layout {
- display: flex;
- gap: 30px;
- align-items: flex-start;
-}
-
-.game-area {
+.btn-ghost {
flex: 1;
- min-width: 0;
+ padding: 13px;
+ font-size: 1.05em;
+ border: 3px solid var(--accent1);
+ background: var(--surface-2);
+ color: var(--text-strong);
}
+.btn-ghost:hover {
+ background: linear-gradient(135deg, var(--accent1), var(--accent-hot));
+ color: #fff;
+ transform: translateY(-3px);
+ box-shadow: 0 10px 20px color-mix(in srgb, var(--accent1) 40%, transparent);
+}
+.btn-tiny {
+ padding: 8px 12px;
+ font-size: 0.85em;
+ background: transparent;
+ color: var(--text-muted);
+ border: 2px solid var(--surface-border);
+ margin-top: 4px;
+}
+.btn-tiny:hover { color: var(--text-strong); border-color: var(--accent1); }
-/* Score Sidebar */
-.score-sidebar {
- width: 220px;
- flex-shrink: 0;
- background: linear-gradient(135deg, rgba(240, 147, 251, 0.15), rgba(79, 172, 254, 0.15));
- border-radius: 20px;
- padding: 25px 20px;
- border: 2px solid rgba(240, 147, 251, 0.3);
+.icon-btn {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 50;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ border: 2px solid var(--surface-border);
+ background: var(--surface);
+ cursor: pointer;
+ font-size: 1.3em;
display: flex;
- flex-direction: column;
- gap: 20px;
-}
-
-.score-title {
- text-align: center;
- font-size: 1.4em;
- font-weight: bold;
- color: #764ba2;
- margin-bottom: 10px;
-}
-
-.score-item {
- display: flex;
- flex-direction: column;
align-items: center;
- gap: 10px;
- padding: 15px;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 15px;
+ justify-content: center;
+ box-shadow: 0 6px 18px rgba(0,0,0,0.25);
+ transition: transform var(--transition), border-color 0.25s;
}
+.icon-btn:hover { transform: scale(1.1); border-color: var(--accent1); }
+.icon-btn .icon-off { display: none; }
+.icon-btn[aria-pressed="true"] .icon-on { display: none; }
+.icon-btn[aria-pressed="true"] .icon-off { display: inline; }
-.score-avatar {
- font-size: 3em;
- animation: bounce 2s infinite;
-}
+/* ---------- Game layout ---------- */
+.game-layout { display: flex; gap: 28px; align-items: flex-start; }
+.game-area { flex: 1; min-width: 0; }
-.score-name {
- font-size: 1em;
- font-weight: bold;
- color: #764ba2;
-}
-
-.score-value {
- font-size: 2.5em;
- font-weight: bold;
- color: #f5576c;
- padding: 8px 20px;
- background: white;
- border-radius: 15px;
- border: 3px solid #f093fb;
- min-width: 70px;
- text-align: center;
- box-shadow: 0 5px 15px rgba(240, 147, 251, 0.3);
-}
-
-.score-divider {
- font-size: 2.5em;
- text-align: center;
- animation: rotate 3s linear infinite;
-}
-
-.score-item.player1-score .score-value {
- color: #f5576c;
- border-color: #f5576c;
-}
-
-.score-item.player2-score .score-value {
- color: #4facfe;
- border-color: #4facfe;
-}
-
-/* Player Info */
.player-info {
display: flex;
justify-content: space-around;
align-items: center;
- margin-bottom: 30px;
- gap: 20px;
+ margin-bottom: 18px;
+ gap: 16px;
}
-
.player-display {
+ flex: 1;
display: flex;
flex-direction: column;
align-items: center;
- padding: 15px 25px;
- border-radius: 20px;
- background: rgba(255, 255, 255, 0.7);
+ padding: 14px 18px;
+ border-radius: var(--radius-md);
+ background: var(--surface-2);
border: 3px solid transparent;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
-
.player-display.active {
- border-color: #f093fb;
- box-shadow: 0 0 30px rgba(240, 147, 251, 0.6);
+ border-color: var(--pc, var(--accent1));
+ box-shadow: 0 0 26px var(--pg, var(--sparkle-glow));
animation: pulse 1.5s ease-in-out infinite;
}
-
-@keyframes pulse {
- 0%, 100% { transform: scale(1); }
- 50% { transform: scale(1.05); }
-}
+@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
.player-glow {
position: absolute;
- top: -50%;
- left: -50%;
- width: 200%;
- height: 200%;
- background: radial-gradient(circle, rgba(240, 147, 251, 0.3), transparent 70%);
+ top: -50%; left: -50%;
+ width: 200%; height: 200%;
+ background: radial-gradient(circle, var(--pg, var(--sparkle-glow)), transparent 70%);
opacity: 0;
transition: opacity 0.3s ease;
}
+.player-display.active .player-glow { opacity: 1; animation: rotate 4s linear infinite; }
+@keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
-.player-display.active .player-glow {
- opacity: 1;
- animation: rotate 3s linear infinite;
-}
-
-@keyframes rotate {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.player-symbol {
- font-size: 2em;
- font-weight: bold;
- margin-bottom: 5px;
-}
-
-.player1-display .player-symbol {
- color: #f5576c;
-}
-
-.player2-display .player-symbol {
- color: #4facfe;
-}
-
+.player-symbol { font-size: 2em; margin-bottom: 4px; position: relative; z-index: 1; filter: drop-shadow(0 0 6px var(--pc, transparent)); }
.player-name {
- font-size: 1.1em;
- font-weight: bold;
- color: #764ba2;
+ font-size: 1.05em; font-weight: 700; color: var(--text-strong);
+ position: relative; z-index: 1; letter-spacing: var(--heading-spacing);
+ max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
-
.vs {
- font-size: 1.5em;
- font-weight: bold;
- color: #764ba2;
+ font-size: 1.4em; font-weight: 700; color: var(--text-strong);
+ font-family: var(--heading-font); letter-spacing: var(--heading-spacing);
}
-/* Game Board */
+/* ---------- Status ---------- */
+.game-status {
+ text-align: center;
+ font-size: 1.4em;
+ font-weight: 700;
+ color: var(--text-strong);
+ min-height: 38px;
+ margin: 8px 0 16px;
+ letter-spacing: var(--heading-spacing);
+}
+.game-status.draw { color: var(--accent-hot); animation: pulse 0.8s ease 3; }
+
+/* ---------- Board ---------- */
.game-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
- gap: 15px;
- margin: 30px 0;
- padding: 20px;
- background: linear-gradient(135deg, rgba(240, 147, 251, 0.2), rgba(79, 172, 254, 0.2));
- border-radius: 20px;
+ gap: 14px;
+ padding: 18px;
+ background: var(--board-bg);
+ border-radius: var(--radius-md);
+ aspect-ratio: 1;
+ max-width: 460px;
+ margin: 0 auto;
}
-
.cell {
aspect-ratio: 1;
- background: white;
- border: 4px solid #f093fb;
- border-radius: 15px;
+ background: var(--cell-bg);
+ border: 4px solid color-mix(in srgb, var(--accent1) 60%, transparent);
+ border-radius: var(--radius-sm);
display: flex;
justify-content: center;
align-items: center;
- font-size: 4em;
- font-weight: bold;
+ font-size: clamp(2rem, 9vw, 3.4rem);
cursor: pointer;
- transition: all 0.3s ease;
+ transition: transform var(--transition), border-color 0.25s, box-shadow 0.25s;
position: relative;
overflow: hidden;
+ font-family: var(--body-font);
+ color: var(--text);
+ padding: 0;
}
-
.cell::before {
content: '';
position: absolute;
- width: 100%;
- height: 100%;
- background: linear-gradient(45deg, transparent, rgba(240, 147, 251, 0.3), transparent);
+ inset: 0;
+ background: linear-gradient(45deg, transparent, color-mix(in srgb, var(--accent1) 30%, transparent), transparent);
transform: translateX(-100%);
transition: transform 0.6s ease;
}
-
-.cell:hover:not(.x):not(.o)::before {
- transform: translateX(100%);
-}
-
-.cell:hover:not(.x):not(.o) {
- transform: scale(1.1);
- border-color: #4facfe;
- box-shadow: 0 0 20px rgba(79, 172, 254, 0.5);
-}
-
-.cell.x,
-.cell.o {
- cursor: not-allowed;
-}
-
-.cell.x {
- color: #f5576c;
- animation: cellAppear 0.3s ease;
-}
-
-.cell.o {
- color: #4facfe;
- animation: cellAppear 0.3s ease;
+.cell:hover:not(.filled)::before { transform: translateX(100%); }
+.cell:hover:not(.filled) {
+ transform: scale(1.08);
+ border-color: var(--accent2);
+ box-shadow: 0 0 18px color-mix(in srgb, var(--accent2) 45%, transparent);
}
+.cell:focus-visible { outline: 3px solid var(--accent2); outline-offset: 3px; }
+.cell.filled { cursor: not-allowed; animation: cellAppear 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
+/* Filled cell takes the placing player's camp color */
+.cell.filled.camp-sparkle { color: var(--sparkle-color); border-color: var(--sparkle-color); box-shadow: 0 0 16px var(--sparkle-glow); }
+.cell.filled.camp-shred { color: var(--shred-color); border-color: var(--shred-color); box-shadow: 0 0 16px var(--shred-glow); }
@keyframes cellAppear {
- from {
- transform: scale(0) rotate(180deg);
- opacity: 0;
- }
- to {
- transform: scale(1) rotate(0deg);
- opacity: 1;
- }
+ from { transform: scale(0) rotate(180deg); opacity: 0; }
+ to { transform: scale(1) rotate(0deg); opacity: 1; }
}
.cell.winning {
animation: winningCell 0.6s ease infinite;
- background: linear-gradient(135deg, #f093fb, #4facfe);
- color: white;
+ color: #fff;
+ border-color: #fff;
}
+.cell.winning.camp-sparkle { background: linear-gradient(135deg, var(--sparkle-color), #4facfe); }
+.cell.winning.camp-shred { background: linear-gradient(135deg, var(--shred-color), #ff6a00); }
+@keyframes winningCell { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } }
-@keyframes winningCell {
- 0%, 100% { transform: scale(1); }
- 50% { transform: scale(1.1); }
-}
+/* ---------- Buttons row ---------- */
+.button-group { display: flex; gap: 14px; margin-top: 20px; }
-/* Game Status */
-.game-status {
- text-align: center;
- font-size: 1.5em;
- font-weight: bold;
- color: #764ba2;
- min-height: 40px;
- margin: 20px 0;
-}
-
-/* Buttons */
-.button-group {
+/* ---------- Scoreboard ---------- */
+.score-sidebar {
+ width: 210px;
+ flex-shrink: 0;
+ background: var(--board-bg);
+ border-radius: var(--radius-md);
+ padding: 22px 18px;
+ border: 2px solid var(--surface-border);
display: flex;
- gap: 15px;
- margin-top: 20px;
+ flex-direction: column;
+ gap: 16px;
}
-
-.game-btn {
- flex: 1;
- padding: 15px;
- font-size: 1.1em;
- font-weight: bold;
- border: 3px solid #f093fb;
- border-radius: 15px;
- background: white;
- color: #764ba2;
- cursor: pointer;
- transition: all 0.3s ease;
-}
-
-.game-btn:hover {
- background: linear-gradient(135deg, #f093fb, #f5576c);
- color: white;
- transform: translateY(-3px);
- box-shadow: 0 10px 20px rgba(240, 147, 251, 0.4);
-}
-
-/* Winner Screen */
-.winner-card {
- background: rgba(255, 255, 255, 0.95);
- border-radius: 30px;
- padding: 60px 40px;
- box-shadow:
- 0 20px 60px rgba(0, 0, 0, 0.3),
- 0 0 40px rgba(246, 79, 254, 0.5),
- inset 0 0 60px rgba(255, 255, 255, 0.5);
- backdrop-filter: blur(10px);
- border: 3px solid rgba(255, 255, 255, 0.8);
+.score-title {
text-align: center;
- position: relative;
- overflow: hidden;
+ font-size: 1.3em;
+ font-weight: 700;
+ color: var(--text-strong);
+ font-family: var(--heading-font);
+ letter-spacing: var(--heading-spacing);
+ text-transform: var(--heading-transform);
+}
+.score-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 14px;
+ background: var(--surface-2);
+ border-radius: var(--radius-sm);
+ border: 2px solid transparent;
+}
+.score-item.camp-sparkle { border-color: color-mix(in srgb, var(--sparkle-color) 40%, transparent); }
+.score-item.camp-shred { border-color: color-mix(in srgb, var(--shred-color) 40%, transparent); }
+.score-avatar { font-size: 2.6em; animation: bounce 2.4s infinite; filter: drop-shadow(0 0 6px var(--pc, transparent)); }
+.score-name {
+ font-size: 0.95em; font-weight: 700; color: var(--text-strong);
+ max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ letter-spacing: var(--heading-spacing);
+}
+.score-value {
+ font-size: 2.2em;
+ font-weight: 700;
+ color: var(--pc, var(--accent-hot));
+ padding: 6px 18px;
+ background: var(--cell-bg);
+ border-radius: var(--radius-sm);
+ border: 3px solid var(--pc, var(--accent1));
+ min-width: 64px;
+ text-align: center;
+ font-family: var(--heading-font);
+}
+.score-divider { font-size: 2.2em; text-align: center; animation: rotate 4s linear infinite; }
+.score-value.score-update { animation: scorePop 0.5s ease; }
+@keyframes scorePop {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.5); box-shadow: 0 0 30px currentColor; }
+ 100% { transform: scale(1); }
}
-.winner-title {
- font-size: 3em;
- background: linear-gradient(45deg, #f093fb, #f5576c, #4facfe);
- background-size: 200% 200%;
- -webkit-background-clip: text;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- animation: gradientText 3s ease infinite;
- margin-bottom: 30px;
- font-weight: bold;
-}
-
-.trophy {
- font-size: 8em;
- animation: trophyBounce 1s ease-in-out infinite;
- margin: 20px 0;
-}
+/* ---------- Winner screen ---------- */
+.winner-title { animation: gradientText 3s ease infinite, popIn 0.6s ease; }
+@keyframes popIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
+.trophy { font-size: 7em; margin: 10px 0; }
+/* Winner card carries the winner's camp; trophy reacts accordingly */
+.winner-card.camp-sparkle .trophy { animation: trophyBounce 1s ease-in-out infinite; }
+.winner-card.camp-shred .trophy { animation: headbang 0.5s ease-in-out infinite; filter: drop-shadow(0 0 16px var(--shred-color)); }
+.winner-card .trophy { animation: trophyBounce 1s ease-in-out infinite; }
@keyframes trophyBounce {
0%, 100% { transform: translateY(0) rotate(-5deg); }
- 50% { transform: translateY(-20px) rotate(5deg); }
+ 50% { transform: translateY(-18px) rotate(5deg); }
+}
+@keyframes headbang {
+ 0%, 100% { transform: translateY(0) rotate(-8deg) scale(1); }
+ 50% { transform: translateY(8px) rotate(8deg) scale(1.08); }
}
.winner-name {
- font-size: 2.5em;
- font-weight: bold;
- color: #764ba2;
- margin: 30px 0;
- text-shadow: 0 0 20px rgba(240, 147, 251, 0.5);
+ font-size: 2.4em;
+ font-weight: 700;
+ color: var(--text-strong);
+ margin: 18px 0 6px;
+ font-family: var(--heading-font);
+ letter-spacing: var(--heading-spacing);
+ text-transform: var(--heading-transform);
+ text-shadow: 0 0 20px var(--pg, var(--sparkle-glow));
}
+.winner-sub { font-size: 1.1em; color: var(--text-muted); margin-bottom: 26px; }
+.winner-buttons { display: flex; flex-direction: column; gap: 12px; max-width: 320px; margin: 0 auto; }
+.winner-buttons .btn-primary { margin-top: 0; }
+.winner-buttons .btn-ghost { flex: none; }
-/* Confetti */
-.confetti {
+.celebration { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
+.confetti-piece {
position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- pointer-events: none;
+ top: -10%;
+ font-size: 1.8em;
+ animation: confettiFall linear infinite;
}
-
-.confetti::before,
-.confetti::after {
- content: '๐ โจ ๐ ๐ซ ๐ โญ';
- position: absolute;
- font-size: 2em;
- animation: confettiFall 3s linear infinite;
- width: 100%;
- text-align: center;
-}
-
-.confetti::after {
- animation-delay: 1.5s;
-}
-
@keyframes confettiFall {
- 0% {
- top: -10%;
- opacity: 1;
- transform: rotate(0deg);
- }
- 100% {
- top: 110%;
- opacity: 0.5;
- transform: rotate(360deg);
- }
+ 0% { transform: translateY(0) rotate(0deg); opacity: 1; }
+ 100% { transform: translateY(120vh) rotate(720deg); opacity: 0.3; }
}
-/* Fireworks */
-.fireworks {
- position: absolute;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
- pointer-events: none;
-}
-
-.firework {
- position: absolute;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- animation: fireworkExplode 2s ease-out infinite;
-}
-
-.firework:nth-child(1) {
- top: 20%;
- left: 20%;
- background: #f093fb;
-}
-
-.firework:nth-child(2) {
- top: 30%;
- right: 20%;
- background: #4facfe;
- animation-delay: 0.7s;
-}
-
-.firework:nth-child(3) {
- bottom: 20%;
- left: 50%;
- background: #f5576c;
- animation-delay: 1.4s;
-}
-
-@keyframes fireworkExplode {
- 0% {
- transform: scale(1);
- opacity: 1;
- box-shadow:
- 0 0 0 0 currentColor,
- 0 0 0 0 currentColor,
- 0 0 0 0 currentColor,
- 0 0 0 0 currentColor;
- }
- 100% {
- transform: scale(2);
- opacity: 0;
- box-shadow:
- 0 -50px 0 5px currentColor,
- 50px 0 0 5px currentColor,
- 0 50px 0 5px currentColor,
- -50px 0 0 5px currentColor;
- }
-}
-
-/* Particle Effects */
+/* ---------- Particle burst (move feedback) ---------- */
.particle {
position: fixed;
pointer-events: none;
@@ -750,83 +717,61 @@ body {
z-index: 9999;
animation: particleBurst 0.8s ease-out forwards;
}
-
@keyframes particleBurst {
- 0% {
- transform: translate(0, 0) scale(1) rotate(0deg);
- opacity: 1;
- }
- 100% {
- transform: translate(var(--tx), var(--ty)) scale(0) rotate(360deg);
- opacity: 0;
- }
+ 0% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; }
+ 100% { transform: translate(var(--tx), var(--ty)) scale(0) rotate(360deg); opacity: 0; }
}
-/* Score Update Animation */
-.score-value.score-update {
- animation: scorePopAnimation 0.5s ease;
+/* ---------- Screen shake (shred move) ---------- */
+.shake { animation: shake 0.25s ease; }
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 25% { transform: translateX(-4px); }
+ 75% { transform: translateX(4px); }
}
-@keyframes scorePopAnimation {
- 0% { transform: scale(1); }
- 50% { transform: scale(1.5); box-shadow: 0 0 30px currentColor; }
- 100% { transform: scale(1); }
-}
-
-/* Responsive Design */
-@media (max-width: 768px) {
- .game-layout {
- flex-direction: column;
- }
-
+/* ---------- Responsive ---------- */
+@media (max-width: 780px) {
+ .game-layout { flex-direction: column; }
.score-sidebar {
width: 100%;
flex-direction: row;
+ flex-wrap: wrap;
justify-content: space-around;
- padding: 20px;
- }
-
- .score-title {
- display: none;
- }
-
- .score-item {
- flex: 1;
- }
-
- .score-divider {
- display: flex;
align-items: center;
+ padding: 16px;
}
+ .score-title { width: 100%; }
+ .score-item { flex: 1; min-width: 120px; }
+ .score-divider { display: flex; align-items: center; }
+ #resetScoresBtn { width: 100%; order: 5; }
}
-@media (max-width: 600px) {
- .title {
- font-size: 2em;
- }
+@media (max-width: 620px) {
+ .players-setup { grid-template-columns: 1fr; gap: 18px; }
+ .name-card, .game-card { padding: 24px 18px; }
+ .winner-card { padding: 40px 22px; }
+ .title { font-size: 2em; }
+ .game-title { font-size: 2.1em; }
+ .winner-title { font-size: 2.4em; }
+ .trophy { font-size: 5.5em; }
+ .button-group { flex-direction: column; }
+ .player-info { gap: 10px; }
+ .vs { display: none; }
+ .avatar-option { font-size: 1.4em; }
+ .stage-desc { display: none; }
+}
- .game-title {
- font-size: 1.8em;
- }
+@media (max-width: 380px) {
+ .stage-options { gap: 8px; }
+ .stage-option { padding: 10px 4px; }
+}
- .cell {
- font-size: 2.5em;
- }
-
- .button-group {
- flex-direction: column;
- }
-
- .player-info {
- flex-direction: column;
- gap: 10px;
- }
-
- .vs {
- display: none;
- }
-
- .container {
- max-width: 100%;
+/* ---------- Reduced motion ---------- */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
}
}