diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index c26101e..76b6b05 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1579,6 +1579,302 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav ) } +/** Halloween mode: on through October, off after. `?halloween=1` forces it on for testing. */ +const HALLOWEEN_KEY = 'rf_halloween_off' +const HALLOWEEN_SPRITES = ['πŸ¦‡', 'πŸ‘»', 'πŸŽƒ', 'πŸ•·οΈ', 'πŸ¦‡', 'πŸ‘»', 'πŸŽƒ', 'πŸ¦‡', 'πŸ‘»', 'πŸŽƒ', 'πŸ•·οΈ', 'πŸ¦‡'] + +const HALLOWEEN_CSS = ` +.rf-halloween { position: fixed; inset: 0; pointer-events: none; z-index: 9998; overflow: hidden; + background: radial-gradient(ellipse at center, transparent 55%, rgba(255, 110, 0, 0.14) 100%); } +.rf-halloween-sprite { position: absolute; top: 0; opacity: 0; animation: rf-drift linear infinite; will-change: transform; } +.rf-halloween-web { position: absolute; top: 0; font-size: 64px; opacity: 0.45; line-height: 1; } +@keyframes rf-drift { + 0% { transform: translate(0, 105vh) rotate(-8deg); opacity: 0; } + 10% { opacity: 0.85; } + 50% { transform: translate(40px, 50vh) rotate(8deg); } + 90% { opacity: 0.85; } + 100% { transform: translate(-30px, -10vh) rotate(-8deg); opacity: 0; } +} +@media (prefers-reduced-motion: reduce) { .rf-halloween-sprite { display: none; } } +.rf-halloween-toggle { position: fixed; left: 12px; bottom: 12px; z-index: 9999; pointer-events: auto; + font-size: 20px; line-height: 1; padding: 6px 8px; border-radius: 999px; cursor: pointer; } +` + +function halloweenActive(): boolean { + if (new Date().getMonth() === 9) return true + return new URLSearchParams(window.location.search).get('halloween') === '1' +} + +/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */ +const PUMPKIN_SRC = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII=' +const PUMPKIN_SCALE = 3 +const PUMPKIN_W = 23 * PUMPKIN_SCALE +const PUMPKIN_H = 25 * PUMPKIN_SCALE +const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', 'πŸŽƒ'] + +const PUMPKIN_CSS = ` +.rf-pumpkin { position: absolute; top: 0; left: 0; width: ${PUMPKIN_W}px; height: ${PUMPKIN_H}px; will-change: transform; } +.rf-pumpkin img { + display: block; + width: ${PUMPKIN_W}px; + height: ${PUMPKIN_H}px; + image-rendering: pixelated; + transform-origin: 50% 100%; +} +.rf-pumpkin.is-walking img { + animation: rf-pumpkin-step 0.24s steps(2, end) infinite; +} +@keyframes rf-pumpkin-step { + 0%, 100% { transform: translateY(0) scaleX(1); } + 50% { transform: translateY(-1px) scaleX(0.96); } +} +@media (prefers-reduced-motion: reduce) { + .rf-pumpkin.is-walking img { animation: none; } +} +.rf-pumpkin-bubble { position: absolute; left: 50%; transform: translateX(-50%); margin: 6px 0; padding: 3px 8px; + border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600; + white-space: nowrap; opacity: 0; transition: opacity 0.15s; } +` + +/** + * A little pumpkin that strolls around the viewport, wanders off to random spots, walks toward the + * cursor (stopping short of it), wobbles when the pointer is over it, and hops and talks when clicked. + * It never captures pointer events, so it can't block anything underneath it. + */ +function LilPumpkin() { + const rootRef = useRef(null) + const flipRef = useRef(null) + const bubbleRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const flip = flipRef.current + const bubble = bubbleRef.current + if (!root || !flip || !bubble) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + root.style.display = 'none' + return + } + + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) + const maxX = () => Math.max(0, window.innerWidth - PUMPKIN_W) + const maxY = () => Math.max(0, window.innerHeight - PUMPKIN_H) + + let x = clamp(window.innerWidth * 0.3, 0, maxX()) + let y = maxY() + let tx = x + let ty = y + let facing = 1 + let pause = 1 + let jy = 0 + let vy = 0 + let walkTime = 0 + let mouseX = -1 + let mouseY = -1 + let mouseAt = -Infinity + let speechTimer = 0 + let last = performance.now() + let raf = 0 + + root.style.transform = `translate(${x}px, ${y}px)` + + const say = (line: string) => { + const nearTop = y + jy < 36 + bubble.style.top = nearTop ? '100%' : 'auto' + bubble.style.bottom = nearTop ? 'auto' : '100%' + bubble.textContent = line + bubble.style.opacity = '1' + window.clearTimeout(speechTimer) + speechTimer = window.setTimeout(() => { + bubble.style.opacity = '0' + }, 1600) + } + + const onMove = (e: MouseEvent) => { + mouseX = e.clientX + mouseY = e.clientY + mouseAt = performance.now() + } + const onLeave = () => { + mouseAt = -Infinity + } + const onClick = (e: MouseEvent) => { + const top = y + jy + const hit = + e.clientX >= x - 6 && + e.clientX <= x + PUMPKIN_W + 6 && + e.clientY >= top - 6 && + e.clientY <= top + PUMPKIN_H + 6 + if (!hit) return + if (jy === 0) vy = -430 + say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)]) + } + const onResize = () => { + x = clamp(x, 0, maxX()) + y = clamp(y, 0, maxY()) + tx = clamp(tx, 0, maxX()) + ty = clamp(ty, 0, maxY()) + } + window.addEventListener('mousemove', onMove) + document.documentElement.addEventListener('mouseleave', onLeave) + window.addEventListener('click', onClick) + window.addEventListener('resize', onResize) + + const frame = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000) + last = now + const following = now - mouseAt < 3000 + let speed = 0 + + if (following) { + const dx = mouseX - (x + PUMPKIN_W / 2) + const dy = mouseY - (y + PUMPKIN_H / 2) + const dist = Math.hypot(dx, dy) + if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1 + if (dist > 110) { + // Walk toward the cursor but stop short, so it never ends up sitting under the pointer. + tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX()) + ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY()) + speed = 150 + } else { + tx = x + ty = y + } + pause = 0.5 + } else if (pause > 0) { + pause -= dt + if (pause <= 0) { + tx = Math.random() * maxX() + ty = Math.random() * maxY() + } + } else { + speed = 60 + } + + const mx = tx - x + const my = ty - y + const md = Math.hypot(mx, my) + let moving = false + if (speed > 0 && md > 2) { + const step = Math.min(md, speed * dt) + x += (mx / md) * step + y += (my / md) * step + moving = true + if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1 + } else if (speed > 0 && !following) { + pause = 1 + Math.random() * 2.5 + } + + if (jy < 0 || vy < 0) { + vy += 1500 * dt + jy += vy * dt + if (jy >= 0) { + jy = 0 + vy = 0 + } + } + + walkTime += moving ? dt : 0 + const bob = moving ? Math.abs(Math.sin(walkTime * 12)) * 7 : 0 + const breath = 1 + Math.sin(now / 400) * 0.03 + const over = + following && + mouseX >= x && + mouseX <= x + PUMPKIN_W && + mouseY >= y + jy && + mouseY <= y + jy + PUMPKIN_H + const rot = over ? Math.sin(now / 60) * 7 : 0 + + root.classList.toggle('is-walking', moving) + root.style.transform = `translate(${x.toFixed(1)}px, ${(y + jy - bob).toFixed(1)}px)` + flip.style.transform = `rotate(${rot.toFixed(1)}deg) scale(${facing}, ${breath.toFixed(3)})` + raf = requestAnimationFrame(frame) + } + raf = requestAnimationFrame(frame) + + return () => { + cancelAnimationFrame(raf) + window.clearTimeout(speechTimer) + window.removeEventListener('mousemove', onMove) + document.documentElement.removeEventListener('mouseleave', onLeave) + window.removeEventListener('click', onClick) + window.removeEventListener('resize', onResize) + } + }, []) + + return ( + <> + +
+
+
+ +
+
+ + ) +} + +/** Drifting bats, ghosts and pumpkins plus corner cobwebs. Never blocks clicks; the corner button turns it off. */ +function HalloweenOverlay() { + const [off, setOff] = useState(() => { + try { + return localStorage.getItem(HALLOWEEN_KEY) === '1' + } catch { + return false + } + }) + if (!halloweenActive()) return null + + const toggle = () => { + const next = !off + setOff(next) + try { + if (next) localStorage.setItem(HALLOWEEN_KEY, '1') + else localStorage.removeItem(HALLOWEEN_KEY) + } catch { + // Storage can be unavailable; the toggle still works for this visit. + } + } + + return ( + <> + + {!off && ( + + )} + + + ) +} + export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) @@ -1692,6 +1988,7 @@ export function App() { )} + ) }