From ef972d8ed244b37a6b8bc4168825f088f2499fc6 Mon Sep 17 00:00:00 2001 From: Tommy Parnell Date: Mon, 26 Oct 2026 10:37:35 -0400 Subject: [PATCH] celebrations --- src/components/PerfectCelebration.js | 94 +++++++++++ src/engine/audio.js | 32 ++++ src/engine/confetti.js | 238 ++++++++++++++++++--------- src/main.js | 48 ++++-- src/styles/components.css | 172 +++++++++++++++++++ src/version.json | 2 +- 6 files changed, 493 insertions(+), 93 deletions(-) create mode 100644 src/components/PerfectCelebration.js diff --git a/src/components/PerfectCelebration.js b/src/components/PerfectCelebration.js new file mode 100644 index 0000000..83c0abb --- /dev/null +++ b/src/components/PerfectCelebration.js @@ -0,0 +1,94 @@ +import { getRankInfo } from '../engine/constants.js'; + +const PRAISE = [ + 'Every. Single. Word. You absolute wordsmith.', + 'Not a fragment left behind. Flawless.', + 'You squeezed every last word out of this board.', + 'A clean sweep. The dictionary bows to you.', + 'Nothing escaped you. That was masterful.', + 'Perfection achieved. Take a bow.' +]; + +const HEADLINES = ['Perfect!', 'Flawless!', 'Clean Sweep!', 'Masterful!']; + +function pick(list) { + return list[Math.floor(Math.random() * list.length)]; +} + +/** + * Celebration card shown when the player finds every word on the board. + */ +export function createPerfectCelebration({ onPlayAnother }) { + const element = document.createElement('div'); + element.className = 'perfect-overlay'; + element.id = 'perfect-overlay'; + element.hidden = true; + + function close() { + element.classList.remove('visible'); + setTimeout(() => { element.hidden = true; }, 300); + } + + function show(state) { + const puzzle = state.currentPuzzle; + const stats = state.getStats(); + const wordsFound = state.foundWords.size; + const totalWords = puzzle?.wordCount || wordsFound; + const rank = getRankInfo(state.score, totalWords, totalWords); + const isFirstPerfect = stats.perfectCount <= 1; + + element.innerHTML = ` + + `; + + element.hidden = false; + requestAnimationFrame(() => element.classList.add('visible')); + + element.querySelector('#btn-perfect-close').addEventListener('click', close); + element.querySelector('#btn-perfect-play-another').addEventListener('click', () => { + close(); + onPlayAnother?.(); + }); + } + + // Click outside the card or press Escape to close + element.addEventListener('click', e => { + if (e.target === element) close(); + }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !element.hidden) close(); + }); + + return { element, show, close, isOpen: () => !element.hidden }; +} diff --git a/src/engine/audio.js b/src/engine/audio.js index 2ae4e0d..6209214 100644 --- a/src/engine/audio.js +++ b/src/engine/audio.js @@ -260,6 +260,38 @@ class SoundEngine { osc.stop(now + delay + 0.36); }); } + + // Triumphant fanfare for a perfect board: rising arpeggio, big chord, sparkles + playPerfect() { + if (this.muted) return; + this.initContext(); + if (!this.ctx) return; + + const now = this.ctx.currentTime; + const tone = (freq, start, dur, type = 'triangle', vol = 0.12) => { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, now + start); + gain.gain.setValueAtTime(0.0001, now + start); + gain.gain.exponentialRampToValueAtTime(vol, now + start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(now + start); + osc.stop(now + start + dur + 0.05); + }; + + // C major arpeggio climbing two octaves + [523.25, 659.25, 783.99, 1046.5, 1318.51, 1567.98].forEach((f, i) => tone(f, i * 0.09, 0.3)); + + // Sustained final chord (C - E - G - C) + [523.25, 659.25, 783.99, 1046.5].forEach(f => tone(f, 0.6, 1.6, 'triangle', 0.1)); + tone(261.63, 0.6, 1.6, 'sine', 0.12); + + // High sparkle shimmer + [2093, 2637, 3136, 2637, 3520, 4186].forEach((f, i) => tone(f, 0.75 + i * 0.11, 0.25, 'sine', 0.04)); + } } export const sound = new SoundEngine(); diff --git a/src/engine/confetti.js b/src/engine/confetti.js index e605182..18fa7fe 100644 --- a/src/engine/confetti.js +++ b/src/engine/confetti.js @@ -1,107 +1,189 @@ /** - * Lightweight canvas confetti system with multi-colored ribbons, stars and sparkles + * Lightweight canvas confetti system with multi-colored ribbons, stars and sparkles. + * All bursts share one canvas and one animation loop, so overlapping bursts + * (e.g. a multi-cannon celebration) don't clobber each other. + */ +const DEFAULT_COLORS = ['#f59e0b', '#10b981', '#6366f1', '#ec4899', '#3b82f6', '#fbbf24']; + +let canvas = null; +let ctx = null; +let particles = []; +let animationFrame = null; + +function ensureCanvas() { + if (!canvas || !canvas.isConnected) { + canvas = document.getElementById('quartiles-confetti-canvas'); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = 'quartiles-confetti-canvas'; + canvas.style.position = 'fixed'; + canvas.style.top = '0'; + canvas.style.left = '0'; + canvas.style.width = '100vw'; + canvas.style.height = '100vh'; + canvas.style.pointerEvents = 'none'; + canvas.style.zIndex = '9999'; + document.body.appendChild(canvas); + } + ctx = canvas.getContext('2d'); + } + // Resize (also resets the transform) only when the viewport changed + const dpr = window.devicePixelRatio || 1; + const w = Math.round(window.innerWidth * dpr); + const h = Math.round(window.innerHeight * dpr); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); +} + +function render() { + ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + + particles = particles.filter(p => p.opacity > 0 && p.y < window.innerHeight + 50); + + for (const p of particles) { + p.vx *= p.drag; + p.vy = p.vy * p.drag + p.gravity; + p.x += p.vx + Math.sin(p.wobble) * p.wobbleAmp; + p.y += p.vy; + p.wobble += p.wobbleSpeed; + p.rotation += p.vRotation; + p.scaleX += p.vScale; + if (Math.abs(p.scaleX) > 1) p.vScale *= -1; + p.opacity -= p.decay; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate((p.rotation * Math.PI) / 180); + ctx.scale(p.scaleX, 1); + ctx.globalAlpha = Math.max(0, p.opacity); + ctx.fillStyle = p.color; + + if (p.shape === 'rect') { + ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size / 1.5); + } else if (p.shape === 'star') { + drawStar(p.size / 2); + } else { + ctx.beginPath(); + ctx.arc(0, 0, p.size / 2.5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + if (particles.length > 0) { + animationFrame = requestAnimationFrame(render); + } else { + ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); + animationFrame = null; + } +} + +function drawStar(r) { + ctx.beginPath(); + for (let i = 0; i < 10; i++) { + const radius = i % 2 === 0 ? r : r * 0.45; + const a = (Math.PI / 5) * i - Math.PI / 2; + ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius); + } + ctx.closePath(); + ctx.fill(); +} + +/** + * Fires one confetti burst. + * @param {object} opts + * @param {number} opts.particleCount + * @param {number} opts.spread Half-angle of the cone in degrees + * @param {number} opts.angle Launch direction in degrees (-90 = straight up) + * @param {{x:number,y:number}} opts.origin Fractions of the viewport + * @param {number} opts.velocity Base launch speed + * @param {number} opts.starRatio Share of particles drawn as stars (0-1) + * @param {number} opts.decay Base fade rate per frame (lower = lasts longer) + * @param {string[]} opts.colors */ export function fireConfetti({ particleCount = 75, spread = 70, + angle = -90, origin = { x: 0.5, y: 0.6 }, - colors = ['#f59e0b', '#10b981', '#6366f1', '#ec4899', '#3b82f6', '#fbbf24'] + velocity = 7, + starRatio = 0, + decay = 0.012, + colors = DEFAULT_COLORS } = {}) { - let canvas = document.getElementById('quartiles-confetti-canvas'); - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.id = 'quartiles-confetti-canvas'; - canvas.style.position = 'fixed'; - canvas.style.top = '0'; - canvas.style.left = '0'; - canvas.style.width = '100vw'; - canvas.style.height = '100vh'; - canvas.style.pointerEvents = 'none'; - canvas.style.zIndex = '9999'; - document.body.appendChild(canvas); - } + ensureCanvas(); - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * dpr; - canvas.height = window.innerHeight * dpr; - ctx.scale(dpr, dpr); - - const particles = []; const startX = origin.x * window.innerWidth; const startY = origin.y * window.innerHeight; for (let i = 0; i < particleCount; i++) { - const angle = (Math.PI / 180) * (-90 + (Math.random() - 0.5) * spread * 2); - const speed = 7 + Math.random() * 9; - const color = colors[Math.floor(Math.random() * colors.length)]; - const size = Math.random() * 8 + 6; - const shape = Math.random() > 0.4 ? 'rect' : 'circle'; + const a = (Math.PI / 180) * (angle + (Math.random() - 0.5) * spread * 2); + const speed = velocity + Math.random() * 9; + const roll = Math.random(); + const shape = roll < starRatio ? 'star' : roll < starRatio + (1 - starRatio) * 0.6 ? 'rect' : 'circle'; particles.push({ x: startX, y: startY, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, + vx: Math.cos(a) * speed, + vy: Math.sin(a) * speed, rotation: Math.random() * 360, vRotation: (Math.random() - 0.5) * 12, scaleX: 1, vScale: (Math.random() - 0.5) * 0.1, - color, - size, + wobble: Math.random() * Math.PI * 2, + wobbleSpeed: 0.05 + Math.random() * 0.08, + wobbleAmp: Math.random() * 0.6, + color: colors[Math.floor(Math.random() * colors.length)], + size: Math.random() * 8 + 6, shape, gravity: 0.28, drag: 0.96, opacity: 1, - decay: 0.012 + Math.random() * 0.008 + decay: decay + Math.random() * 0.008 }); } - let animationFrame; - - function render() { - ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); - - let activeCount = 0; - - for (let i = 0; i < particles.length; i++) { - const p = particles[i]; - if (p.opacity <= 0) continue; - - activeCount++; - p.vx *= p.drag; - p.vy = p.vy * p.drag + p.gravity; - p.x += p.vx; - p.y += p.vy; - p.rotation += p.vRotation; - p.scaleX += p.vScale; - if (Math.abs(p.scaleX) > 1) p.vScale *= -1; - p.opacity -= p.decay; - - ctx.save(); - ctx.translate(p.x, p.y); - ctx.rotate((p.rotation * Math.PI) / 180); - ctx.scale(p.scaleX, 1); - ctx.globalAlpha = Math.max(0, p.opacity); - ctx.fillStyle = p.color; - - if (p.shape === 'rect') { - ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size / 1.5); - } else { - ctx.beginPath(); - ctx.arc(0, 0, p.size / 2.5, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } - - if (activeCount > 0) { - animationFrame = requestAnimationFrame(render); - } else { - ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); - cancelAnimationFrame(animationFrame); - } + if (!animationFrame) { + animationFrame = requestAnimationFrame(render); + } +} + +/** + * Multi-stage celebration for a perfect board: a center blast, side cannons, + * then a slow golden rain of stars from the top of the screen. + */ +export function fireConfettiShow() { + const gold = ['#fbbf24', '#f59e0b', '#fde68a', '#ffffff', '#facc15']; + + fireConfetti({ particleCount: 180, spread: 110, origin: { x: 0.5, y: 0.65 }, velocity: 9, starRatio: 0.2 }); + + setTimeout(() => { + fireConfetti({ particleCount: 110, spread: 30, angle: -60, origin: { x: 0, y: 0.8 }, velocity: 14, starRatio: 0.15 }); + fireConfetti({ particleCount: 110, spread: 30, angle: -120, origin: { x: 1, y: 0.8 }, velocity: 14, starRatio: 0.15 }); + }, 350); + + setTimeout(() => { + fireConfetti({ particleCount: 90, spread: 25, angle: -70, origin: { x: 0.1, y: 0.95 }, velocity: 15, starRatio: 0.3 }); + fireConfetti({ particleCount: 90, spread: 25, angle: -110, origin: { x: 0.9, y: 0.95 }, velocity: 15, starRatio: 0.3 }); + }, 900); + + // Golden star rain drifting down across the whole width + for (let i = 0; i < 6; i++) { + setTimeout(() => { + fireConfetti({ + particleCount: 30, + spread: 25, + angle: 90, + origin: { x: 0.1 + Math.random() * 0.8, y: -0.05 }, + velocity: 1, + starRatio: 0.6, + decay: 0.006, + colors: gold + }); + }, 1300 + i * 250); } - - render(); } diff --git a/src/main.js b/src/main.js index 57341ac..0802200 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,7 @@ import './styles/components.css'; import { gameState } from './state/game-state.js'; import { getDailyPuzzle, resolveBuiltInPuzzle, toDateString } from './engine/daily.js'; import { sound } from './engine/audio.js'; -import { fireConfetti } from './engine/confetti.js'; +import { fireConfetti, fireConfettiShow } from './engine/confetti.js'; import { createHeader } from './components/Header.js'; import { createScoreRankBar } from './components/ScoreRankBar.js'; @@ -16,6 +16,7 @@ import { createWordsDrawer } from './components/WordsDrawer.js'; import { createModals } from './components/Modals.js'; import { createToastContainer } from './components/Toast.js'; import { createUpdateBanner } from './components/UpdateBanner.js'; +import { createPerfectCelebration } from './components/PerfectCelebration.js'; import { APP_VERSION, startVersionCheck } from './engine/version.js'; // PWA Service Worker Registration (production only: in dev it would cache @@ -102,18 +103,7 @@ async function initApp() { onOpenStats: () => modals.open('stats', gameState), onOpenCustomBuilder: () => modals.open('builder', gameState), onOpenSettings: () => modals.open('settings', gameState), - onNewRandomPuzzle: async () => { - try { - const { generateRandomPuzzle } = await import('./engine/generator.js'); - const randomPuzzle = generateRandomPuzzle(); - gameState.loadPuzzle(randomPuzzle); - gameState.notifyToast('Loaded fresh unlimited puzzle!', 'success'); - fireConfetti({ particleCount: 30, spread: 50 }); - } catch (err) { - console.error('Failed to generate puzzle:', err); - gameState.notifyToast('Failed to generate puzzle', 'error'); - } - } + onNewRandomPuzzle: () => loadRandomPuzzle() }); appContainer.appendChild(header.element); @@ -197,6 +187,31 @@ async function initApp() { document.body.appendChild(updateBanner.element); startVersionCheck(latest => updateBanner.show(latest)); + // Loads a fresh unlimited puzzle (header dice button + celebration card) + async function loadRandomPuzzle() { + try { + const { generateRandomPuzzle } = await import('./engine/generator.js'); + const randomPuzzle = generateRandomPuzzle(); + gameState.loadPuzzle(randomPuzzle); + gameState.notifyToast('Loaded fresh unlimited puzzle!', 'success'); + fireConfetti({ particleCount: 30, spread: 50 }); + } catch (err) { + console.error('Failed to generate puzzle:', err); + gameState.notifyToast('Failed to generate puzzle', 'error'); + } + } + + // Perfect board celebration (every word on the list found) + const perfectCelebration = createPerfectCelebration({ onPlayAnother: loadRandomPuzzle }); + document.body.appendChild(perfectCelebration.element); + + function celebratePerfect() { + sound.playPerfect(); + fireConfettiShow(); + gameState.notifyToast('👑 PERFECT! Every word found!', 'quartile', 4000); + setTimeout(() => perfectCelebration.show(gameState), 900); + } + // Word submission handler function handleWordSubmission() { const prevRank = gameState.getRank(); @@ -233,7 +248,10 @@ async function initApp() { // Check rank progression const currentRank = gameState.getRank(); - if (!prevRank.isExpert && currentRank.isExpert) { + if (!prevRank.isPerfect && currentRank.isPerfect) { + // Perfect outranks Expert; let the 5/5 bonus fanfare finish first if it just played + setTimeout(celebratePerfect, result.bonusUnlocked ? 1600 : 500); + } else if (!prevRank.isExpert && currentRank.isExpert) { setTimeout(() => { sound.playRankUp(); gameState.notifyToast('🏆 EXPERT RANK ACHIEVED! Outstanding!', 'quartile', 4000); @@ -250,6 +268,8 @@ async function initApp() { window.addEventListener('keydown', e => { // If inside an input field, do not hijack if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + // Celebration card handles its own keys (Escape closes it) + if (perfectCelebration.isOpen()) return; if (e.key === 'Enter') { e.preventDefault(); diff --git a/src/styles/components.css b/src/styles/components.css index 0afd402..766ce4c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -928,3 +928,175 @@ font-variant-numeric: tabular-nums; letter-spacing: 0.02em; } + +/* ========================================================================== + PERFECT BOARD CELEBRATION + ========================================================================== */ + +.perfect-overlay { + position: fixed; + inset: 0; + z-index: 1200; /* below the confetti canvas (9999) so confetti falls over the card */ + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + transition: opacity 0.3s ease; +} + +.perfect-overlay.visible { + opacity: 1; +} + +.perfect-card { + position: relative; + overflow: hidden; + width: min(380px, 100%); + padding: 30px 24px 22px; + text-align: center; + background: var(--bg-secondary); + border: 1.5px solid rgba(245, 158, 11, 0.45); + border-radius: 24px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(245, 158, 11, 0.08); + transform: scale(0.85) translateY(20px); + transition: transform 0.45s var(--ease-spring); +} + +.perfect-overlay.visible .perfect-card { + transform: scale(1) translateY(0); +} + +.perfect-glow { + position: absolute; + inset: -40% -20% auto; + height: 70%; + background: radial-gradient(circle, rgba(251, 191, 36, 0.35), transparent 65%); + pointer-events: none; + animation: perfectGlow 3s ease-in-out infinite; +} + +@keyframes perfectGlow { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.08); } +} + +.perfect-crown { + position: relative; + font-size: 3.6rem; + line-height: 1; + margin-bottom: 6px; + filter: drop-shadow(0 6px 14px rgba(245, 158, 11, 0.45)); + animation: crownDrop 0.8s var(--ease-spring) 0.15s both, crownFloat 2.6s ease-in-out 1s infinite; +} + +@keyframes crownDrop { + 0% { transform: translateY(-60px) rotate(-20deg) scale(0.4); opacity: 0; } + 100% { transform: translateY(0) rotate(0) scale(1); opacity: 1; } +} + +@keyframes crownFloat { + 0%, 100% { transform: translateY(0) rotate(0); } + 50% { transform: translateY(-6px) rotate(4deg); } +} + +.perfect-title { + position: relative; + margin: 0 0 6px; + font-size: 2.2rem; + font-weight: 900; + letter-spacing: -0.02em; + background: var(--accent-quartile-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.perfect-praise { + position: relative; + margin: 0 auto 18px; + max-width: 290px; + font-size: 0.95rem; + color: var(--text-secondary); +} + +.perfect-stats { + position: relative; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 14px; +} + +.perfect-stat { + padding: 10px 4px; + background: var(--bg-primary); + border-radius: var(--radius-md); + animation: statPop 0.4s var(--ease-spring) both; +} + +.perfect-stat:nth-child(1) { animation-delay: 0.35s; } +.perfect-stat:nth-child(2) { animation-delay: 0.45s; } +.perfect-stat:nth-child(3) { animation-delay: 0.55s; } + +@keyframes statPop { + 0% { transform: scale(0.6); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.perfect-stat-value { + font-size: 1.5rem; + font-weight: 800; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.perfect-stat-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-secondary); +} + +.perfect-milestone { + position: relative; + margin: 0 0 18px; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent-quartile); +} + +.perfect-actions { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; +} + +.perfect-btn-primary { + height: 46px; + border-radius: var(--radius-md); + background: var(--accent-quartile-gradient); + color: #ffffff; + font-weight: 800; + font-size: 1rem; + box-shadow: 0 6px 18px rgba(245, 158, 11, 0.35); + transition: transform var(--transition-fast); +} + +.perfect-btn-primary:hover { + transform: translateY(-1px); +} + +.perfect-btn-secondary { + height: 40px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); + color: var(--text-primary); + font-weight: 700; + font-size: 0.9rem; +} diff --git a/src/version.json b/src/version.json index ca84e70..9a8bfb2 100644 --- a/src/version.json +++ b/src/version.json @@ -1,3 +1,3 @@ { - "version": "1.0.1" + "version": "1.0.2" }