mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
Add Halloween overlay and walking pumpkin (#117)
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user