Pumple Evil Edition >:) (#138)

* Add Halloween overlay and walking pumpkin

* Add Pumple chaos Halloween minigame

---------

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
Jake Vice
2026-10-23 16:45:12 -05:00
committed by GitHub
parent 3da35a8692
commit f9600c1836
11 changed files with 355 additions and 24 deletions
+104 -24
View File
@@ -30,6 +30,7 @@ import {
import { DeviceLinkPage } from './DeviceLink' import { DeviceLinkPage } from './DeviceLink'
import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item' import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item'
import { ModerationPage, PlayerBanStatus } from './Moderation' import { ModerationPage, PlayerBanStatus } from './Moderation'
import { PumpleBossFight } from './PumpleBossFight'
import { StatsPage } from './Stats' import { StatsPage } from './Stats'
import { StudioAccessPage } from './StudioAccess' import { StudioAccessPage } from './StudioAccess'
@@ -1777,11 +1778,18 @@ function halloweenActive(): boolean {
} }
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */ /** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
const PUMPKIN_SRC = const PUMPKIN_FRAMES = [
'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=' new URL('./pumple-frames/frame-00.png', import.meta.url).href,
const PUMPKIN_SCALE = 3 new URL('./pumple-frames/frame-01.png', import.meta.url).href,
const PUMPKIN_W = 23 * PUMPKIN_SCALE new URL('./pumple-frames/frame-02.png', import.meta.url).href,
const PUMPKIN_H = 25 * PUMPKIN_SCALE new URL('./pumple-frames/frame-03.png', import.meta.url).href,
new URL('./pumple-frames/frame-04.png', import.meta.url).href,
new URL('./pumple-frames/frame-05.png', import.meta.url).href,
new URL('./pumple-frames/frame-06.png', import.meta.url).href,
new URL('./pumple-frames/frame-07.png', import.meta.url).href,
]
const PUMPKIN_W = 150
const PUMPKIN_H = 150
const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃'] const PUMPKIN_LINES = ['boo!', 'hehe', 'hey!', 'spooky!', 'happy halloween!', 'ow!', '🎃']
const PUMPKIN_CSS = ` const PUMPKIN_CSS = `
@@ -1793,16 +1801,7 @@ const PUMPKIN_CSS = `
image-rendering: pixelated; image-rendering: pixelated;
transform-origin: 50% 100%; transform-origin: 50% 100%;
} }
.rf-pumpkin.is-walking img { /* The animated GIF provides its own walking frames. */
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; .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; border-radius: 10px; background: #fff3dc; color: #2a1608; font-size: 12px; font-weight: 600;
white-space: nowrap; opacity: 0; transition: opacity 0.15s; } white-space: nowrap; opacity: 0; transition: opacity 0.15s; }
@@ -1816,13 +1815,15 @@ const PUMPKIN_CSS = `
function LilPumpkin() { function LilPumpkin() {
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const flipRef = useRef<HTMLDivElement>(null) const flipRef = useRef<HTMLDivElement>(null)
const spriteRef = useRef<HTMLImageElement>(null)
const bubbleRef = useRef<HTMLDivElement>(null) const bubbleRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const root = rootRef.current const root = rootRef.current
const flip = flipRef.current const flip = flipRef.current
const sprite = spriteRef.current
const bubble = bubbleRef.current const bubble = bubbleRef.current
if (!root || !flip || !bubble) return if (!root || !flip || !sprite || !bubble) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
root.style.display = 'none' root.style.display = 'none'
return return
@@ -1841,12 +1842,65 @@ function LilPumpkin() {
let jy = 0 let jy = 0
let vy = 0 let vy = 0
let walkTime = 0 let walkTime = 0
let walkDistance = 0
let walkFrame = 0
let mouseX = -1 let mouseX = -1
let mouseY = -1 let mouseY = -1
let mouseAt = -Infinity let mouseAt = -Infinity
let speechTimer = 0 let speechTimer = 0
let last = performance.now() let last = performance.now()
let raf = 0 let raf = 0
const effectNodes: HTMLElement[] = []
const spawnBatSwarm = () => {
const flash = document.createElement('div')
Object.assign(flash.style, {
position: 'fixed', inset: '0',
background: 'rgba(210, 0, 28, 0.32)',
opacity: '1', transition: 'opacity 480ms ease-out',
pointerEvents: 'none', zIndex: '100000',
})
document.body.appendChild(flash)
effectNodes.push(flash)
requestAnimationFrame(() => { flash.style.opacity = '0' })
window.setTimeout(() => {
flash.remove()
const i = effectNodes.indexOf(flash)
if (i !== -1) effectNodes.splice(i, 1)
}, 520)
const cx = x + PUMPKIN_W / 2
const cy = y + jy + PUMPKIN_H / 2
for (let i = 0; i < 22; i++) {
const bat = document.createElement('span')
const angle = Math.random() * Math.PI * 2
const distance = 100 + Math.random() * 260
const dx = Math.cos(angle) * distance
const dy = Math.sin(angle) * distance
Object.assign(bat.style, {
position: 'fixed', left: `${cx}px`, top: `${cy}px`,
fontSize: `${20 + Math.random() * 20}px`,
lineHeight: '1', pointerEvents: 'none',
zIndex: '100001', opacity: '1',
transform: 'translate(-50%, -50%) scale(0.4)',
transition: 'transform 850ms ease-out, opacity 850ms ease-out',
willChange: 'transform, opacity',
})
bat.textContent = '🦇'
document.body.appendChild(bat)
effectNodes.push(bat)
requestAnimationFrame(() => {
bat.style.transform =
`translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${Math.random() * 100 - 50}deg) scale(1.2)`
bat.style.opacity = '0'
})
window.setTimeout(() => {
bat.remove()
const i = effectNodes.indexOf(bat)
if (i !== -1) effectNodes.splice(i, 1)
}, 900)
}
}
root.style.transform = `translate(${x}px, ${y}px)` root.style.transform = `translate(${x}px, ${y}px)`
@@ -1870,14 +1924,30 @@ function LilPumpkin() {
const onLeave = () => { const onLeave = () => {
mouseAt = -Infinity mouseAt = -Infinity
} }
let rapidClickCount = 0
let rapidClickStart = 0
const onClick = (e: MouseEvent) => { const onClick = (e: MouseEvent) => {
const top = y + jy const top = y + jy
const bob = 0
const hit = const hit =
e.clientX >= x - 6 && e.clientX >= x - 45 &&
e.clientX <= x + PUMPKIN_W + 6 && e.clientX <= x + PUMPKIN_W + 45 &&
e.clientY >= top - 6 && e.clientY >= top - bob - 45 &&
e.clientY <= top + PUMPKIN_H + 6 e.clientY <= top - bob + PUMPKIN_H + 45
if (!hit) return if (!hit) return
const now = performance.now()
if (now - rapidClickStart > 5000) {
rapidClickStart = now
rapidClickCount = 0
}
rapidClickCount++
if (rapidClickCount >= 10) {
rapidClickCount = 0
rapidClickStart = 0
window.dispatchEvent(new CustomEvent('rf-pumple-boss'))
}
spawnBatSwarm()
if (jy === 0) vy = -430 if (jy === 0) vy = -430
say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)]) say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)])
} }
@@ -1903,11 +1973,11 @@ function LilPumpkin() {
const dy = mouseY - (y + PUMPKIN_H / 2) const dy = mouseY - (y + PUMPKIN_H / 2)
const dist = Math.hypot(dx, dy) const dist = Math.hypot(dx, dy)
if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1 if (Math.abs(dx) > 4) facing = dx > 0 ? 1 : -1
if (dist > 110) { if (dist > 85) {
// Walk toward the cursor but stop short, so it never ends up sitting under the pointer. // 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()) tx = clamp(mouseX - (dx / dist) * 80 - PUMPKIN_W / 2, 0, maxX())
ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY()) ty = clamp(mouseY - (dy / dist) * 80 - PUMPKIN_H / 2, 0, maxY())
speed = 150 speed = 310
} else { } else {
tx = x tx = x
ty = y ty = y
@@ -1920,7 +1990,7 @@ function LilPumpkin() {
ty = Math.random() * maxY() ty = Math.random() * maxY()
} }
} else { } else {
speed = 60 speed = 145
} }
const mx = tx - x const mx = tx - x
@@ -1932,6 +2002,15 @@ function LilPumpkin() {
x += (mx / md) * step x += (mx / md) * step
y += (my / md) * step y += (my / md) * step
moving = true moving = true
// Advance the walk cycle according to distance actually traveled.
walkDistance += step
const nextFrame = Math.floor(walkDistance / 30) % PUMPKIN_FRAMES.length
if (nextFrame !== walkFrame) {
walkFrame = nextFrame
sprite.src = PUMPKIN_FRAMES[walkFrame]
}
if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1 if (!following && Math.abs(mx) > 4) facing = mx > 0 ? -1 : 1
} else if (speed > 0 && !following) { } else if (speed > 0 && !following) {
pause = 1 + Math.random() * 2.5 pause = 1 + Math.random() * 2.5
@@ -1980,7 +2059,7 @@ function LilPumpkin() {
<div className="rf-pumpkin" ref={rootRef}> <div className="rf-pumpkin" ref={rootRef}>
<div className="rf-pumpkin-bubble" ref={bubbleRef} /> <div className="rf-pumpkin-bubble" ref={bubbleRef} />
<div ref={flipRef} style={{ transformOrigin: '50% 100%' }}> <div ref={flipRef} style={{ transformOrigin: '50% 100%' }}>
<img src={PUMPKIN_SRC} alt="" draggable={false} /> <img ref={spriteRef} src={PUMPKIN_FRAMES[0]} alt="" draggable={false} />
</div> </div>
</div> </div>
</> </>
@@ -2175,6 +2254,7 @@ export function App() {
)} )}
<SiteFooter /> <SiteFooter />
<HalloweenOverlay /> <HalloweenOverlay />
<PumpleBossFight />
</> </>
) )
} }
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react'
type Mob = { id: number; x: number; y: number; hp: number }
type Shot = { id: number; x: number; y: number; vx: number; vy: number; bomb?: boolean }
type Game = {
player: number
hp: number
bossHp: number
mobs: Mob[]
enemyShots: Shot[]
arrows: Shot[]
bombs: number
kills: number
waves: number
nextId: number
lastSpawn: number
lastAttack: number
phase: 'fight' | 'victory' | 'defeat'
}
const freshGame = (): Game => ({
player: (typeof window !== 'undefined' ? window.innerWidth : 1024) / 2, hp: 100, bossHp: 100,
mobs: [], enemyShots: [], arrows: [], bombs: 0, kills: 0,
waves: 1, nextId: 1, lastSpawn: 0, lastAttack: 0, phase: 'fight',
})
const css = `
.rf-boss{position:fixed;inset:0;z-index:100010;overflow:hidden;background:radial-gradient(ellipse at 50% 34%,#542619 0%,#26121a 43%,#090810 100%);color:#fff0d5;font-family:system-ui,sans-serif;cursor:crosshair;user-select:none}
.rf-boss:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#08060a66,transparent 35%,#05050988);z-index:0}
.rf-boss:after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.22;background-image:radial-gradient(#ffb35b 1px,transparent 1px);background-size:37px 37px;mask-image:linear-gradient(transparent,#000 30%,#000)}
.rf-boss *{box-sizing:border-box}
.rf-boss-hud{position:absolute;z-index:8;top:18px;left:50%;transform:translateX(-50%);width:min(900px,82%);display:grid;grid-template-columns:1.2fr 1fr;gap:22px;padding:16px 20px;border:1px solid #e9843c88;border-radius:15px;background:linear-gradient(135deg,#160d12eF,#2a1117eF);box-shadow:0 10px 45px #0009,inset 0 1px #ffd2a322;backdrop-filter:blur(8px)}
.rf-boss-label{font-weight:950;letter-spacing:2px;font-size:11px;text-transform:uppercase;margin-bottom:8px;color:#ffddb2}
.rf-boss-track{height:17px;border:1px solid #ffd9aa88;background:#100a10;border-radius:5px;overflow:hidden;box-shadow:inset 0 2px 5px #000a}
.rf-boss-fill{height:100%;background:linear-gradient(90deg,#a82420,#ff6036,#ffb05b);box-shadow:0 0 15px #ff552a99;transition:width .12s}
.rf-boss-player .rf-boss-fill{background:linear-gradient(90deg,#138a65,#49e7a0);box-shadow:0 0 14px #3df0a088}
.rf-boss-monster{position:absolute;z-index:4;top:16%;left:50%;width:min(43vw,390px);height:min(43vw,390px);min-width:230px;min-height:230px;transform:translateX(-50%);display:grid;place-items:center;filter:drop-shadow(0 0 18px #ff5b24aa);animation:rf-boss-hover 3s ease-in-out infinite}
.rf-boss-monster:before{content:"";position:absolute;width:90%;height:35%;bottom:0;border-radius:50%;background:#ff5a1726;filter:blur(25px)}
.rf-boss-monster img{position:relative;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;filter:drop-shadow(0 12px 0 #150a10) drop-shadow(0 0 16px #ff6b35aa)}
.rf-boss-minion{position:absolute;z-index:4;transform:translate(-50%,-50%);width:clamp(44px,7vw,78px);height:clamp(44px,7vw,78px);filter:drop-shadow(0 0 10px #ff4b2588);pointer-events:none}
.rf-boss-minion img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated}
.rf-boss-shot{position:absolute;z-index:5;transform:translate(-50%,-50%);font-size:25px;pointer-events:none;filter:drop-shadow(0 0 9px #ff712f);animation:rf-shot-glow .5s ease-in-out infinite alternate}
.rf-boss-player-char{position:absolute;z-index:5;bottom:7%;font-size:42px;transform:translateX(-50%);width:64px;height:64px;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle,#77eaff35,transparent 70%);filter:drop-shadow(0 0 10px #7ad9ff);pointer-events:none}
.rf-boss-hint{position:absolute;z-index:8;bottom:14px;left:50%;transform:translateX(-50%);width:max-content;max-width:88%;text-align:center;font-size:11px;font-weight:850;letter-spacing:1px;color:#ffe3c1;padding:12px 18px;border:1px solid #b9684777;border-radius:9px;background:#100b13dc;box-shadow:0 4px 25px #0008}
.rf-boss-vines{position:absolute;z-index:6;inset:0;pointer-events:none;overflow:hidden;opacity:1}
.rf-boss-vines svg{position:absolute;inset:-2%;width:104%;height:104%;overflow:visible}
.rf-boss-vines .vine-main{fill:none;stroke:#172f1b;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 0 5px #050807)}
.rf-boss-vines .vine-edge{fill:none;stroke:#426a2d;stroke-width:12;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:25 8;animation:rf-vine-crawl 7s linear infinite}
.rf-boss-vines .vine-glow{fill:none;stroke:#df722a;stroke-width:3;stroke-linecap:round;stroke-dasharray:3 28;animation:rf-vine-crawl 4s linear infinite}
.rf-boss-vines .thorn{fill:#72913b;stroke:#18291a;stroke-width:3}
.rf-boss-vines .root{fill:none;stroke:#253d20;stroke-width:39;stroke-linecap:round;opacity:.92}
.rf-boss-vines-retreat{animation:rf-vine-retreat 2.8s ease-in forwards}
.rf-boss-end{position:absolute;z-index:10;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;background:linear-gradient(180deg,#08090bea,#130d0bea);padding:24px}
.rf-boss-panel{max-width:560px;width:100%;padding:clamp(26px,5vw,44px);border:2px solid #9b542d;border-radius:4px;background:#171310;box-shadow:8px 8px 0 #050505, inset 0 0 0 4px #171310, inset 0 0 0 5px #443023;color:#eee2ce}
.rf-boss-title{font-family:Impact,'Arial Narrow',system-ui,sans-serif;font-size:clamp(34px,6.5vw,60px);font-weight:900;line-height:1.05;letter-spacing:1px;text-transform:uppercase;color:#f0a04d;text-shadow:3px 3px 0 #522719;margin:0 0 20px}
.rf-boss-button{margin:10px 6px 0;border:2px solid #c47b43;border-radius:3px;padding:12px 20px;background:#30251b;color:#ffe5c3;font-family:inherit;font-size:13px;font-weight:800;letter-spacing:1px;text-transform:uppercase;cursor:pointer;box-shadow:0 3px 0 #090807;transition:background .12s,transform .12s}
.rf-boss-button:hover{background:#513522;transform:translateY(-2px)}
@keyframes rf-boss-hover{0%,100%{translate:0 0}50%{translate:0 -8px}}
@keyframes rf-shot-glow{from{scale:.9}to{scale:1.12}}
@keyframes rf-vine-crawl{to{stroke-dashoffset:-132}}
@keyframes rf-vine-retreat{to{transform:scale(1.4);opacity:0}}
@media(max-width:600px){.rf-boss-hud{top:10px;width:86%;padding:12px;gap:12px}.rf-boss-label{font-size:9px;letter-spacing:1px}.rf-boss-track{height:13px}.rf-boss-monster{top:23%;width:65vw;height:65vw;min-width:0;min-height:0}.rf-boss-hint{font-size:9px;letter-spacing:.3px;padding:10px;bottom:8px}}
@media(prefers-reduced-motion:reduce){.rf-boss-monster,.rf-boss-shot,.rf-boss-vines .vine-edge,.rf-boss-vines .vine-glow{animation:none}}
`
export function PumpleBossFight() {
const [active, setActive] = useState(false)
const [view, setView] = useState<Game>(freshGame)
const game = useRef<Game>(freshGame())
const mouse = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .78 : 600 })
const aim = useRef({ x: typeof window !== 'undefined' ? window.innerWidth / 2 : 512, y: typeof window !== 'undefined' ? window.innerHeight * .3 : 200 })
const started = useRef(false)
useEffect(() => {
const start = () => {
if (started.current) return
started.current = true
game.current = freshGame()
mouse.current = { x: innerWidth / 2, y: innerHeight * .78 }
setView({ ...game.current })
setActive(true)
}
window.addEventListener('rf-pumple-boss', start)
return () => window.removeEventListener('rf-pumple-boss', start)
}, [])
useEffect(() => {
if (!active) return
const g = game.current
let last = performance.now()
let frame = 0
let endTimer = 0
const tick = () => {
const now = performance.now()
const dt = Math.min(.05, (now - last) / 1000)
last = now
if (g.phase === 'fight') {
g.player = Math.max(28, Math.min(innerWidth - 28, mouse.current.x))
const difficulty = 1 + (g.waves - 1) * .18
if (now - g.lastSpawn > Math.max(900, 3000 / difficulty) && g.mobs.length < 5 + g.waves) {
g.lastSpawn = now
g.mobs.push({ id: g.nextId++, x: Math.random() * (innerWidth - 80) + 40, y: innerHeight * (.35 + Math.random() * .28), hp: 2 })
}
if (now - g.lastAttack > Math.max(450, 1400 / difficulty)) {
g.lastAttack = now
const sx = innerWidth / 2, sy = innerHeight * .29
const dx = g.player - sx, dy = innerHeight * .78 - sy
const d = Math.hypot(dx, dy) || 1
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 280 * difficulty, vy: dy / d * 280 * difficulty })
if (g.waves >= 2) {
g.enemyShots.push({ id: g.nextId++, x: sx, y: sy, vx: -dx / d * 240 * difficulty, vy: 270 * difficulty })
}
}
for (const m of g.mobs) {
const dx = g.player - m.x, dy = innerHeight * .78 - m.y, d = Math.hypot(dx, dy) || 1
m.x += dx / d * 75 * difficulty * dt
m.y += dy / d * 75 * difficulty * dt
if (d < 35) { g.hp -= 18 * dt; m.hp = 0 }
}
for (const s of g.enemyShots) { s.x += s.vx * dt; s.y += s.vy * dt }
g.enemyShots = g.enemyShots.filter(s => {
if (Math.hypot(s.x - g.player, s.y - innerHeight * .78) < 32) { g.hp -= 12; return false }
return s.x > -50 && s.x < innerWidth + 50 && s.y < innerHeight + 50
})
for (const s of g.arrows) {
s.x += s.vx * dt; s.y += s.vy * dt
if (s.bomb) {
let detonated = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 65) {
m.hp = 0
detonated = true
}
}
if (Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 115) {
g.bossHp -= 14
detonated = true
}
if (detonated) s.x = -999
} else {
let hit = false
for (const m of g.mobs) {
if (m.hp > 0 && Math.hypot(s.x - m.x, s.y - m.y) < 28) {
m.hp = 0
hit = true
break
}
}
if (!hit && Math.hypot(s.x - innerWidth / 2, s.y - innerHeight * .29) < 90) {
g.bossHp -= 2
hit = true
}
if (hit) s.x = -999
}
}
g.arrows = g.arrows.filter(s => s.x > -100 && s.x < innerWidth + 100 && s.y > -100 && s.y < innerHeight + 100)
// Count defeated minions before removing them; some kills drop bombs.
const dead = g.mobs.filter(m => m.hp <= 0)
if (dead.length) {
g.kills += dead.length
for (let i = 0; i < dead.length; i++) {
if (Math.random() < .38) g.bombs++
}
}
g.mobs = g.mobs.filter(m => m.hp > 0)
if (g.bossHp <= 0) g.phase = 'victory'
else if (g.hp <= 0) g.phase = 'defeat'
if (g.kills >= g.waves * 5) g.waves = Math.min(8, Math.floor(g.kills / 5) + 1)
}
setView({ ...g, mobs: [...g.mobs], enemyShots: [...g.enemyShots], arrows: [...g.arrows] })
if (g.phase !== 'fight') {
window.clearInterval(frame)
endTimer = window.setTimeout(() => {
if (g.phase === 'defeat') window.location.reload()
}, 6500)
}
}
frame = window.setInterval(tick, 50) as unknown as number
return () => { window.clearInterval(frame); window.clearTimeout(endTimer) }
}, [active])
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
mouse.current = { x: e.clientX, y: e.clientY }
aim.current = { x: e.clientX, y: e.clientY }
}
const fire = (e: React.MouseEvent<HTMLDivElement>) => {
if (view.phase !== 'fight') return
const g = game.current
const sx = g.player, sy = innerHeight * .78
const dx = e.clientX - sx, dy = e.clientY - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 620, vy: dy / d * 620 })
}
const bomb = () => {
const g = game.current
if (!active || g.phase !== 'fight' || g.bombs <= 0) return
g.bombs--
const sx = g.player, sy = innerHeight * .78
const dx = aim.current.x - sx, dy = aim.current.y - sy, d = Math.hypot(dx, dy) || 1
g.arrows.push({ id: g.nextId++, x: sx, y: sy, vx: dx / d * 350, vy: dy / d * 350, bomb: true })
setView({ ...g, arrows: [...g.arrows] })
}
useEffect(() => {
if (!active) return
const key = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault(); bomb() } }
window.addEventListener('keydown', key)
return () => window.removeEventListener('keydown', key)
}, [active])
if (!active) return null
const ended = view.phase !== 'fight'
return <div className="rf-boss" onMouseMove={onMove} onClick={fire} onContextMenu={e => e.preventDefault()}>
<style>{css}</style>
<div className="rf-boss-hud">
<div style={{ flex: 1 }}><div className="rf-boss-label">YOUR HEALTH {Math.max(0, Math.ceil(view.hp))}/100</div><div className="rf-boss-track rf-boss-player"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.hp)}%` }} /></div></div>
<div style={{ flex: 1 }}><div className="rf-boss-label">PUMPLE {Math.max(0, Math.ceil(view.bossHp))}%</div><div className="rf-boss-track"><div className="rf-boss-fill" style={{ width: `${Math.max(0, view.bossHp)}%` }} /></div></div>
</div>
<div className="rf-boss-monster">
<img src={new URL('./pumple-frames/frame-04.png', import.meta.url).href} alt="Pumple, the pumpkin king" draggable={false} />
</div>
{view.mobs.map(m => <div key={m.id} className="rf-boss-minion" style={{ left: m.x, top: m.y }}>
<img src={new URL('./Pumple_monster_walk.gif', import.meta.url).href} alt="" draggable={false} />
</div>)}
{view.enemyShots.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>🔥</div>)}
{view.arrows.map(s => <div key={s.id} className="rf-boss-shot" style={{ left: s.x, top: s.y }}>{s.bomb ? '💣' : '➶'}</div>)}
<div className="rf-boss-player-char" style={{ left: view.player }}>🛡️</div>
<div className="rf-boss-hint">MOVE YOUR MOUSE TO DODGE · CLICK TO SHOOT ARROWS · SPACE: PUMPKIN BOMB ({view.bombs}) · WAVE {view.waves}</div>
<div className={`rf-boss-vines ${view.phase === 'victory' ? 'rf-boss-vines-retreat' : ''}`}>
<svg viewBox="0 0 1200 800" preserveAspectRatio="none" aria-hidden="true">
<path className="root" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<path className="vine-main" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-edge" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760 M-20 300 Q100 250 160 330 T250 390 M1220 450 Q1100 390 1050 460 T960 510"/>
<path className="vine-glow" d="M-30 30 Q70 90 45 190 T120 340 T65 520 T150 830 M1230 20 Q1110 95 1155 210 T1080 370 T1160 560 T1050 830 M-20 790 Q100 700 210 780 T390 760 M1220 790 Q1100 700 990 780 T810 760"/>
<g className="thorn">
<path d="M54 180 l-35 -18 27 43z M94 305 l-36 -8 30 31z M81 500 l-33 -18 23 39z M1140 200 l34 -24 -24 48z M1100 360 l37 -12 -29 36z M1134 545 l36 -19 -23 43z M190 760 l-5 -40 27 31z M1010 760 l6 -40 -28 31z M180 345 l-25 -28 7 40z M1035 455 l24 -28 -7 40z"/>
</g>
</svg>
</div>
{ended && <><div className="rf-boss-end"><div className="rf-boss-panel">
<h1 className="rf-boss-title">{view.phase === 'victory' ? 'PUMPLE DEFEATED!' : 'RECFLARE HAS FALLEN'}</h1>
<p>{view.phase === 'victory' ? 'The pumpkin king crumbles into embers. The vines retreat and RecFlare is saved!' : 'The vines have consumed the battlefield. Pumple claims RecFlare...'}</p>
{view.phase === 'victory' && <p>Waves reached: {view.waves} · Minions defeated: {view.kills} · Bombs remaining: {view.bombs}</p>}
{view.phase === 'victory' && <button className="rf-boss-button" onClick={e => { e.stopPropagation(); setActive(false); started.current = false }}>RETURN TO RECFLARE</button>}
{view.phase === 'defeat' && <p>The site will reset after the vines take over...</p>}
</div></div></>}
</div>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB