diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7917b22 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep the build context tiny โ€” only the static assets + configs are needed. +.git +.gitignore +.dockerignore +Dockerfile +docker-compose.yml +README.md +test/ +.playwright-mcp/ +screenshots/ +node_modules/ +*.log +.DS_Store +.idea/ +.vscode/ diff --git a/.gitignore b/.gitignore index 91c2279..a619d03 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,11 @@ Thumbs.db # Test / tooling artifacts /screenshots/ +.playwright-mcp/ +node_modules/ *.log + +# Local Claude config + tooling artifacts +.claude/ +*.png +*.jpeg diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0faa10d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Ultimate Tic-Tac-Toe โ€” static site served by nginx. +# No build step: copy the static assets into the nginx web root. +FROM nginx:1.27-alpine + +# Tuned server config (gzip, cache + security headers, SPA-safe fallback). +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Static assets. +COPY index.html style.css script.js /usr/share/nginx/html/ + +EXPOSE 80 + +# Liveness probe for `docker ps` / orchestration. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 3d0c5dc..ad0eb5f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,68 @@ -# Kara's ultimate tic tac toe +# Ultimate Tic-Tac-Toe -## Objective +A two-player, hot-seat tic-tac-toe game for the browser where **each player brings their own flavor**. -Design an ultimate browser based tic tac toe game with the following features: +Every player picks **any** avatar from a combined roster โ€” sparkle *or* shred โ€” and that choice sets their personal color, move sound, and particle effects. One player can rock ๐Ÿฆ„ unicorns and rainbows while the other brings ๐Ÿ’€ skulls and metal. A separate **stage** selector sets the global vibe. +## Camps & stages +**Camps** (per player, set by the chosen avatar): + +- **โœจ Sparkle** โ€” โญ๐Ÿฆ„๐ŸŒธ๐ŸŽ€๐Ÿฆ‹๐ŸŒˆ๐ŸŽจ๐Ÿญ ยท pink glow, bright sine chimes, sparkle particles. +- **๐Ÿ’€ Shred** โ€” ๐Ÿ’€โ˜ ๏ธ๐ŸŽธ๐Ÿค˜๐Ÿ”ฅโšก๐Ÿดโ€โ˜ ๏ธ๐Ÿ‰ ยท red glow, sawtooth power-chords, fire/lightning particles, board shake. + +**Stages** (global, pick on the setup screen): + +- **โœจ Sparkle** โ€” pastel gradient, glassmorphism cards, twinkling stars. +- **๐Ÿ’€ Shred** โ€” black & red, rising embers, lightning flashes, metal lettering. +- **โš”๏ธ Mixed** โ€” neutral dark arena where both camps' colors pop; both backdrops play. Great when the two players don't agree. + +## Features + +- **Free-for-all avatars** โ€” each player's grid shows all 16; a guard stops both choosing the *same* one. +- **Per-player identity** carries through the whole game: side panels, scoreboard, and each player's pieces on the board glow in their camp color, with camp-specific move sounds and particle bursts. +- **Three stages** driven entirely by CSS custom properties (`[data-stage]`); the winner screen adopts the winner's camp. +- **Web Audio** SFX (no asset files); mute toggle, top-right. +- **Persistent** names, avatars, stage, scores, and mute via `localStorage`. +- **Accessible** โ€” keyboard-navigable board (arrow keys + Enter/Space), ARIA roles and live status, visible focus rings, `prefers-reduced-motion`. +- **Responsive & scroll-safe** โ€” no horizontal overflow or clipped content from 320 px to desktop (the old nested-scroll / clipped-top bug is fixed: the layout now centers with `margin:auto` instead of fragile flex centering + `max-height`). + +## Run it + +Static site, no build step. + +**Local web server** (so `localStorage` + audio behave like production) +```bash +python3 -m http.server 8000 +# โ†’ http://localhost:8000 +``` + +**Docker / OrbStack** (nginx, production-style headers) +```bash +docker compose up -d --build +# โ†’ http://localhost:8080 +docker compose down +``` + +## Test + +End-to-end tests drive real Chromium against the running app via `playwright-core` and print a plain-text PASS/FAIL report (avatar roster, mixed-camp play, stage switching, win/draw flows, persistence, responsive overflow at five widths, keyboard a11y, console cleanliness). Screenshots land in `screenshots/`. + +```bash +# with a server (or the container) running on :8080 +PW_CORE="$(node -e "console.log(require.resolve('playwright-core'))")" \ +node test/e2e.mjs http://localhost:8080 +# optional: PW_CHROMIUM=/path/to/Chromium to pin a browser build +``` + +## Project structure + +``` +index.html markup; stage selector + three screens (setup / game / winner) +style.css stages ([data-stage]) + per-player camps (.camp-sparkle/.camp-shred) +script.js engine: avatar roster, camps, stages, audio, FX, persistence, a11y +Dockerfile nginx:alpine static image +nginx.conf gzip, cache + security headers +docker-compose.yml one-command run on :8080 via OrbStack +test/e2e.mjs Playwright end-to-end suite +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b571dde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +# Run the game locally with OrbStack's Docker engine: +# docker compose up -d --build +# open http://localhost:8080 +# docker compose down +services: + tictactoe: + build: . + image: tictactoe:latest + container_name: tictactoe + ports: + - "8080:80" + restart: unless-stopped diff --git a/index.html b/index.html index 86132ef..66ed417 100644 --- a/index.html +++ b/index.html @@ -1,130 +1,149 @@ - + - โœจ Ultimate Tic-Tac-Toe โœจ + Ultimate Tic-Tac-Toe + + + + -
+ + + + +
- +
-
-

โœจ Ultimate Tic-Tac-Toe โœจ

+
+

Ultimate Tic-Tac-Toe

-
- - + +
+ Choose your arena +
+ + + +
-
- - -
+

Each player picks any avatar โ€” sparkle or shred, your call. ๐ŸŒˆ๐Ÿค˜

-
-

Choose Your Avatars!

-
-
- -
-
โญ
-
๐Ÿฆ„
-
๐ŸŒธ
-
๐ŸŽ€
-
๐Ÿฆ‹
-
๐ŸŒˆ
-
๐ŸŽจ
-
๐ŸŽช
-
+
+
+
+ +
- -
-
โญ
-
๐Ÿฆ„
-
๐ŸŒธ
-
๐ŸŽ€
-
๐Ÿฆ‹
-
๐ŸŒˆ
-
๐ŸŽจ
-
๐ŸŽช
-
+ Pick an avatar +
+
+
+ +
+
+ + +
+
+ Pick an avatar +
-
-
-

โœจ Tic-Tac-Toe โœจ

+
+

Tic-Tac-Toe

-
- X - + โญ + Player 1
-
VS
+
VS
- O - + ๐Ÿ’€ + Player 2
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+ + + + + + + + + +
- - + +
-
-

Scoreboard

+

Scoreboard

โญ Player 1 0
-
๐Ÿ†
+
๐Ÿ†
- ๐ŸŒธ + ๐Ÿ’€ Player 2 0
+
@@ -132,18 +151,16 @@
-
-
-

๐ŸŽ‰ 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; } }