diff --git a/web/src/composables/useEasterEgg.ts b/web/src/composables/useEasterEgg.ts index 62c48e7..3e3e0ad 100644 --- a/web/src/composables/useEasterEgg.ts +++ b/web/src/composables/useEasterEgg.ts @@ -41,3 +41,114 @@ export function useHackerMode() { return { toggle, restore } } + +/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */ +export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) { + if (typeof requestAnimationFrame === 'undefined') return + + const canvas = document.createElement('canvas') + canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;' + canvas.width = window.innerWidth + canvas.height = window.innerHeight + document.body.appendChild(canvas) + const ctx = canvas.getContext('2d')! + + const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50'] + const particles = Array.from({ length: 80 }, () => ({ + x: originX, + y: originY, + vx: (Math.random() - 0.5) * 14, + vy: (Math.random() - 0.6) * 12, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + size: 5 + Math.random() * 6, + angle: Math.random() * Math.PI * 2, + spin: (Math.random() - 0.5) * 0.3, + life: 1.0, + })) + + let raf = 0 + function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height) + let alive = false + for (const p of particles) { + p.x += p.vx + p.y += p.vy + p.vy += 0.35 // gravity + p.vx *= 0.98 // air friction + p.angle += p.spin + p.life -= 0.016 + if (p.life <= 0) continue + alive = true + ctx.save() + ctx.globalAlpha = p.life + ctx.fillStyle = p.color + ctx.translate(p.x, p.y) + ctx.rotate(p.angle) + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6) + ctx.restore() + } + if (alive) { + raf = requestAnimationFrame(draw) + } else { + cancelAnimationFrame(raf) + canvas.remove() + } + } + raf = requestAnimationFrame(draw) +} + +/** Enable cursor trail in hacker mode โ returns a cleanup function. */ +export function useCursorTrail() { + const DOT_COUNT = 10 + const dots: HTMLElement[] = [] + let positions: { x: number; y: number }[] = [] + let mouseX = 0 + let mouseY = 0 + let raf = 0 + + for (let i = 0; i < DOT_COUNT; i++) { + const dot = document.createElement('div') + dot.style.cssText = [ + 'position:fixed', + 'pointer-events:none', + 'z-index:9998', + 'border-radius:50%', + 'background:#5A9DBF', + 'transition:opacity 0.1s', + ].join(';') + document.body.appendChild(dot) + dots.push(dot) + } + + function onMouseMove(e: MouseEvent) { + mouseX = e.clientX + mouseY = e.clientY + } + + function animate() { + positions.unshift({ x: mouseX, y: mouseY }) + if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT) + + dots.forEach((dot, i) => { + const pos = positions[i] + if (!pos) { dot.style.opacity = '0'; return } + const scale = 1 - i / DOT_COUNT + const size = Math.round(8 * scale) + dot.style.left = `${pos.x - size / 2}px` + dot.style.top = `${pos.y - size / 2}px` + dot.style.width = `${size}px` + dot.style.height = `${size}px` + dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}` + }) + raf = requestAnimationFrame(animate) + } + + window.addEventListener('mousemove', onMouseMove) + raf = requestAnimationFrame(animate) + + return function cleanup() { + window.removeEventListener('mousemove', onMouseMove) + cancelAnimationFrame(raf) + dots.forEach(d => d.remove()) + } +} diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue index de15f30..246bcf7 100644 --- a/web/src/views/LabelView.vue +++ b/web/src/views/LabelView.vue @@ -6,9 +6,12 @@ {{ store.totalRemaining }} remaining - ๐ฅ On a roll! - โก Speed round! - ๐ฏ Fifty deep! + ๐ฅ On a roll! + โก Speed round! + ๐ฏ Fifty deep! + ๐ฏ Century! + ๐งน Clean sweep! + ๐ฆ Midnight labeler!