celebrations
This commit is contained in:
94
src/components/PerfectCelebration.js
Normal file
94
src/components/PerfectCelebration.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -260,6 +260,38 @@ class SoundEngine {
|
|||||||
osc.stop(now + delay + 0.36);
|
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();
|
export const sound = new SoundEngine();
|
||||||
|
|||||||
@@ -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({
|
export function fireConfetti({
|
||||||
particleCount = 75,
|
particleCount = 75,
|
||||||
spread = 70,
|
spread = 70,
|
||||||
|
angle = -90,
|
||||||
origin = { x: 0.5, y: 0.6 },
|
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');
|
ensureCanvas();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 startX = origin.x * window.innerWidth;
|
||||||
const startY = origin.y * window.innerHeight;
|
const startY = origin.y * window.innerHeight;
|
||||||
|
|
||||||
for (let i = 0; i < particleCount; i++) {
|
for (let i = 0; i < particleCount; i++) {
|
||||||
const angle = (Math.PI / 180) * (-90 + (Math.random() - 0.5) * spread * 2);
|
const a = (Math.PI / 180) * (angle + (Math.random() - 0.5) * spread * 2);
|
||||||
const speed = 7 + Math.random() * 9;
|
const speed = velocity + Math.random() * 9;
|
||||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
const roll = Math.random();
|
||||||
const size = Math.random() * 8 + 6;
|
const shape = roll < starRatio ? 'star' : roll < starRatio + (1 - starRatio) * 0.6 ? 'rect' : 'circle';
|
||||||
const shape = Math.random() > 0.4 ? 'rect' : 'circle';
|
|
||||||
|
|
||||||
particles.push({
|
particles.push({
|
||||||
x: startX,
|
x: startX,
|
||||||
y: startY,
|
y: startY,
|
||||||
vx: Math.cos(angle) * speed,
|
vx: Math.cos(a) * speed,
|
||||||
vy: Math.sin(angle) * speed,
|
vy: Math.sin(a) * speed,
|
||||||
rotation: Math.random() * 360,
|
rotation: Math.random() * 360,
|
||||||
vRotation: (Math.random() - 0.5) * 12,
|
vRotation: (Math.random() - 0.5) * 12,
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
vScale: (Math.random() - 0.5) * 0.1,
|
vScale: (Math.random() - 0.5) * 0.1,
|
||||||
color,
|
wobble: Math.random() * Math.PI * 2,
|
||||||
size,
|
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,
|
shape,
|
||||||
gravity: 0.28,
|
gravity: 0.28,
|
||||||
drag: 0.96,
|
drag: 0.96,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
decay: 0.012 + Math.random() * 0.008
|
decay: decay + Math.random() * 0.008
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let animationFrame;
|
if (!animationFrame) {
|
||||||
|
animationFrame = requestAnimationFrame(render);
|
||||||
function render() {
|
}
|
||||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
}
|
||||||
|
|
||||||
let activeCount = 0;
|
/**
|
||||||
|
* Multi-stage celebration for a perfect board: a center blast, side cannons,
|
||||||
for (let i = 0; i < particles.length; i++) {
|
* then a slow golden rain of stars from the top of the screen.
|
||||||
const p = particles[i];
|
*/
|
||||||
if (p.opacity <= 0) continue;
|
export function fireConfettiShow() {
|
||||||
|
const gold = ['#fbbf24', '#f59e0b', '#fde68a', '#ffffff', '#facc15'];
|
||||||
activeCount++;
|
|
||||||
p.vx *= p.drag;
|
fireConfetti({ particleCount: 180, spread: 110, origin: { x: 0.5, y: 0.65 }, velocity: 9, starRatio: 0.2 });
|
||||||
p.vy = p.vy * p.drag + p.gravity;
|
|
||||||
p.x += p.vx;
|
setTimeout(() => {
|
||||||
p.y += p.vy;
|
fireConfetti({ particleCount: 110, spread: 30, angle: -60, origin: { x: 0, y: 0.8 }, velocity: 14, starRatio: 0.15 });
|
||||||
p.rotation += p.vRotation;
|
fireConfetti({ particleCount: 110, spread: 30, angle: -120, origin: { x: 1, y: 0.8 }, velocity: 14, starRatio: 0.15 });
|
||||||
p.scaleX += p.vScale;
|
}, 350);
|
||||||
if (Math.abs(p.scaleX) > 1) p.vScale *= -1;
|
|
||||||
p.opacity -= p.decay;
|
setTimeout(() => {
|
||||||
|
fireConfetti({ particleCount: 90, spread: 25, angle: -70, origin: { x: 0.1, y: 0.95 }, velocity: 15, starRatio: 0.3 });
|
||||||
ctx.save();
|
fireConfetti({ particleCount: 90, spread: 25, angle: -110, origin: { x: 0.9, y: 0.95 }, velocity: 15, starRatio: 0.3 });
|
||||||
ctx.translate(p.x, p.y);
|
}, 900);
|
||||||
ctx.rotate((p.rotation * Math.PI) / 180);
|
|
||||||
ctx.scale(p.scaleX, 1);
|
// Golden star rain drifting down across the whole width
|
||||||
ctx.globalAlpha = Math.max(0, p.opacity);
|
for (let i = 0; i < 6; i++) {
|
||||||
ctx.fillStyle = p.color;
|
setTimeout(() => {
|
||||||
|
fireConfetti({
|
||||||
if (p.shape === 'rect') {
|
particleCount: 30,
|
||||||
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size / 1.5);
|
spread: 25,
|
||||||
} else {
|
angle: 90,
|
||||||
ctx.beginPath();
|
origin: { x: 0.1 + Math.random() * 0.8, y: -0.05 },
|
||||||
ctx.arc(0, 0, p.size / 2.5, 0, Math.PI * 2);
|
velocity: 1,
|
||||||
ctx.fill();
|
starRatio: 0.6,
|
||||||
}
|
decay: 0.006,
|
||||||
ctx.restore();
|
colors: gold
|
||||||
}
|
});
|
||||||
|
}, 1300 + i * 250);
|
||||||
if (activeCount > 0) {
|
|
||||||
animationFrame = requestAnimationFrame(render);
|
|
||||||
} else {
|
|
||||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
|
||||||
cancelAnimationFrame(animationFrame);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/main.js
48
src/main.js
@@ -5,7 +5,7 @@ import './styles/components.css';
|
|||||||
import { gameState } from './state/game-state.js';
|
import { gameState } from './state/game-state.js';
|
||||||
import { getDailyPuzzle, resolveBuiltInPuzzle, toDateString } from './engine/daily.js';
|
import { getDailyPuzzle, resolveBuiltInPuzzle, toDateString } from './engine/daily.js';
|
||||||
import { sound } from './engine/audio.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 { createHeader } from './components/Header.js';
|
||||||
import { createScoreRankBar } from './components/ScoreRankBar.js';
|
import { createScoreRankBar } from './components/ScoreRankBar.js';
|
||||||
@@ -16,6 +16,7 @@ import { createWordsDrawer } from './components/WordsDrawer.js';
|
|||||||
import { createModals } from './components/Modals.js';
|
import { createModals } from './components/Modals.js';
|
||||||
import { createToastContainer } from './components/Toast.js';
|
import { createToastContainer } from './components/Toast.js';
|
||||||
import { createUpdateBanner } from './components/UpdateBanner.js';
|
import { createUpdateBanner } from './components/UpdateBanner.js';
|
||||||
|
import { createPerfectCelebration } from './components/PerfectCelebration.js';
|
||||||
import { APP_VERSION, startVersionCheck } from './engine/version.js';
|
import { APP_VERSION, startVersionCheck } from './engine/version.js';
|
||||||
|
|
||||||
// PWA Service Worker Registration (production only: in dev it would cache
|
// PWA Service Worker Registration (production only: in dev it would cache
|
||||||
@@ -102,18 +103,7 @@ async function initApp() {
|
|||||||
onOpenStats: () => modals.open('stats', gameState),
|
onOpenStats: () => modals.open('stats', gameState),
|
||||||
onOpenCustomBuilder: () => modals.open('builder', gameState),
|
onOpenCustomBuilder: () => modals.open('builder', gameState),
|
||||||
onOpenSettings: () => modals.open('settings', gameState),
|
onOpenSettings: () => modals.open('settings', gameState),
|
||||||
onNewRandomPuzzle: async () => {
|
onNewRandomPuzzle: () => 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
appContainer.appendChild(header.element);
|
appContainer.appendChild(header.element);
|
||||||
|
|
||||||
@@ -197,6 +187,31 @@ async function initApp() {
|
|||||||
document.body.appendChild(updateBanner.element);
|
document.body.appendChild(updateBanner.element);
|
||||||
startVersionCheck(latest => updateBanner.show(latest));
|
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
|
// Word submission handler
|
||||||
function handleWordSubmission() {
|
function handleWordSubmission() {
|
||||||
const prevRank = gameState.getRank();
|
const prevRank = gameState.getRank();
|
||||||
@@ -233,7 +248,10 @@ async function initApp() {
|
|||||||
|
|
||||||
// Check rank progression
|
// Check rank progression
|
||||||
const currentRank = gameState.getRank();
|
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(() => {
|
setTimeout(() => {
|
||||||
sound.playRankUp();
|
sound.playRankUp();
|
||||||
gameState.notifyToast('🏆 EXPERT RANK ACHIEVED! Outstanding!', 'quartile', 4000);
|
gameState.notifyToast('🏆 EXPERT RANK ACHIEVED! Outstanding!', 'quartile', 4000);
|
||||||
@@ -250,6 +268,8 @@ async function initApp() {
|
|||||||
window.addEventListener('keydown', e => {
|
window.addEventListener('keydown', e => {
|
||||||
// If inside an input field, do not hijack
|
// If inside an input field, do not hijack
|
||||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
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') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -928,3 +928,175 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
letter-spacing: 0.02em;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.1"
|
"version": "1.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user