Add Halloween overlay and walking pumpkin (#117)

This commit is contained in:
Mrmadman1
2026-10-08 23:08:33 -05:00
committed by GitHub
parent 74bab0f552
commit f9beba0dae
+297
View File
@@ -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<HTMLDivElement>(null)
const flipRef = useRef<HTMLDivElement>(null)
const bubbleRef = useRef<HTMLDivElement>(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 (
<>
<style>{PUMPKIN_CSS}</style>
<div className="rf-pumpkin" ref={rootRef}>
<div className="rf-pumpkin-bubble" ref={bubbleRef} />
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
<img src={PUMPKIN_SRC} alt="" draggable={false} />
</div>
</div>
</>
)
}
/** 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 (
<>
<style>{HALLOWEEN_CSS}</style>
{!off && (
<div className="rf-halloween" aria-hidden="true">
<span className="rf-halloween-web" style={{ left: 0 }}>🕸️</span>
<span className="rf-halloween-web" style={{ right: 0, transform: 'scaleX(-1)' }}>🕸️</span>
<LilPumpkin />
{HALLOWEEN_SPRITES.map((emoji, i) => (
<span
key={i}
className="rf-halloween-sprite"
style={{
left: `${(i * 37 + 5) % 96}vw`,
fontSize: `${22 + (i % 4) * 8}px`,
animationDuration: `${14 + (i % 5) * 3}s`,
animationDelay: `-${(i * 2.3).toFixed(1)}s`,
}}
>
{emoji}
</span>
))}
</div>
)}
<button
type="button"
className="rf-halloween-toggle"
onClick={toggle}
title={off ? 'Turn Halloween mode on' : 'Turn Halloween mode off'}
aria-label={off ? 'Turn Halloween mode on' : 'Turn Halloween mode off'}
style={{ opacity: off ? 0.5 : 1 }}
>
🎃
</button>
</>
)
}
export function App() {
// undefined = still checking the session; null = signed out.
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
@@ -1692,6 +1988,7 @@ export function App() {
<HomePage account={account} config={config} navigate={navigate} />
)}
<SiteFooter />
<HalloweenOverlay />
</>
)
}