celebrations

This commit is contained in:
2026-10-26 10:37:35 -04:00
parent f1a13a3065
commit ef972d8ed2
6 changed files with 493 additions and 93 deletions

View File

@@ -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 = `
<div class="perfect-card" role="dialog" aria-modal="true" aria-labelledby="perfect-title">
<div class="perfect-glow"></div>
<div class="perfect-crown">${rank.current.badge}</div>
<h2 class="perfect-title" id="perfect-title">${pick(HEADLINES)}</h2>
<p class="perfect-praise">${pick(PRAISE)}</p>
<div class="perfect-stats">
<div class="perfect-stat">
<div class="perfect-stat-value">${state.score}</div>
<div class="perfect-stat-label">Points</div>
</div>
<div class="perfect-stat">
<div class="perfect-stat-value">${wordsFound}</div>
<div class="perfect-stat-label">Words</div>
</div>
<div class="perfect-stat">
<div class="perfect-stat-value">5/5</div>
<div class="perfect-stat-label">Quartiles</div>
</div>
</div>
<p class="perfect-milestone">
${isFirstPerfect
? '🎖️ Your first perfect board!'
: `🎖️ Perfect board #${stats.perfectCount}. You're on a roll.`}
</p>
<div class="perfect-actions">
<button class="perfect-btn-primary" id="btn-perfect-play-another">Play Another</button>
<button class="perfect-btn-secondary" id="btn-perfect-close">Admire My Board</button>
</div>
</div>
`;
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 };
}

View File

@@ -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();

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -1,3 +1,3 @@
{
"version": "1.0.1"
"version": "1.0.2"
}