diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 208b134..1b8f5a8 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -30,6 +30,7 @@ import { import { DeviceLinkPage } from './DeviceLink' import { customAvatarItemIdFromPath, inventionIdFromPath, InventionPage, ItemPage } from './Item' import { ModerationPage, PlayerBanStatus } from './Moderation' +import { PumpleBossFight } from './PumpleBossFight' import { StatsPage } from './Stats' import { StudioAccessPage } from './StudioAccess' @@ -1777,11 +1778,18 @@ function halloweenActive(): boolean { } /** 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_FRAMES = [ + new URL('./pumple-frames/frame-00.png', import.meta.url).href, + new URL('./pumple-frames/frame-01.png', import.meta.url).href, + new URL('./pumple-frames/frame-02.png', import.meta.url).href, + 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_CSS = ` @@ -1793,16 +1801,7 @@ const PUMPKIN_CSS = ` 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; } -} +/* The animated GIF provides its own walking frames. */ .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; } @@ -1816,13 +1815,15 @@ const PUMPKIN_CSS = ` function LilPumpkin() { const rootRef = useRef(null) const flipRef = useRef(null) + const spriteRef = useRef(null) const bubbleRef = useRef(null) useEffect(() => { const root = rootRef.current const flip = flipRef.current + const sprite = spriteRef.current const bubble = bubbleRef.current - if (!root || !flip || !bubble) return + if (!root || !flip || !sprite || !bubble) return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { root.style.display = 'none' return @@ -1841,12 +1842,65 @@ function LilPumpkin() { let jy = 0 let vy = 0 let walkTime = 0 + let walkDistance = 0 + let walkFrame = 0 let mouseX = -1 let mouseY = -1 let mouseAt = -Infinity let speechTimer = 0 let last = performance.now() 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)` @@ -1870,14 +1924,30 @@ function LilPumpkin() { const onLeave = () => { mouseAt = -Infinity } + let rapidClickCount = 0 + let rapidClickStart = 0 const onClick = (e: MouseEvent) => { const top = y + jy + const bob = 0 const hit = - e.clientX >= x - 6 && - e.clientX <= x + PUMPKIN_W + 6 && - e.clientY >= top - 6 && - e.clientY <= top + PUMPKIN_H + 6 + e.clientX >= x - 45 && + e.clientX <= x + PUMPKIN_W + 45 && + e.clientY >= top - bob - 45 && + e.clientY <= top - bob + PUMPKIN_H + 45 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 say(PUMPKIN_LINES[Math.floor(Math.random() * PUMPKIN_LINES.length)]) } @@ -1903,11 +1973,11 @@ function LilPumpkin() { 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) { + if (dist > 85) { // 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 + speed = 310 } else { tx = x ty = y @@ -1920,7 +1990,7 @@ function LilPumpkin() { ty = Math.random() * maxY() } } else { - speed = 60 + speed = 145 } const mx = tx - x @@ -1932,6 +2002,15 @@ function LilPumpkin() { x += (mx / md) * step y += (my / md) * step 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 } else if (speed > 0 && !following) { pause = 1 + Math.random() * 2.5 @@ -1980,7 +2059,7 @@ function LilPumpkin() {
- +
@@ -2175,6 +2254,7 @@ export function App() { )} + ) } diff --git a/apps/www/src/client/PumpleBossFight.tsx b/apps/www/src/client/PumpleBossFight.tsx new file mode 100644 index 0000000..7a37a29 --- /dev/null +++ b/apps/www/src/client/PumpleBossFight.tsx @@ -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(freshGame) + const game = useRef(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) => { + mouse.current = { x: e.clientX, y: e.clientY } + aim.current = { x: e.clientX, y: e.clientY } + } + const fire = (e: React.MouseEvent) => { + 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
e.preventDefault()}> + +
+
YOUR HEALTH {Math.max(0, Math.ceil(view.hp))}/100
+
PUMPLE {Math.max(0, Math.ceil(view.bossHp))}%
+
+
+ Pumple, the pumpkin king +
+ {view.mobs.map(m =>
+ +
)} + {view.enemyShots.map(s =>
🔥
)} + {view.arrows.map(s =>
{s.bomb ? '💣' : '➶'}
)} +
🛡️
+
MOVE YOUR MOUSE TO DODGE · CLICK TO SHOOT ARROWS · SPACE: PUMPKIN BOMB ({view.bombs}) · WAVE {view.waves}
+
+ +
+ {ended && <>
+

{view.phase === 'victory' ? 'PUMPLE DEFEATED!' : 'RECFLARE HAS FALLEN'}

+

{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...'}

+ {view.phase === 'victory' &&

Waves reached: {view.waves} · Minions defeated: {view.kills} · Bombs remaining: {view.bombs}

} + {view.phase === 'victory' && } + {view.phase === 'defeat' &&

The site will reset after the vines take over...

} +
} +
+} diff --git a/apps/www/src/client/Pumple_monster_walk.gif b/apps/www/src/client/Pumple_monster_walk.gif new file mode 100755 index 0000000..d9a7f99 Binary files /dev/null and b/apps/www/src/client/Pumple_monster_walk.gif differ diff --git a/apps/www/src/client/pumple-frames/frame-00.png b/apps/www/src/client/pumple-frames/frame-00.png new file mode 100644 index 0000000..fbd6f14 Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-00.png differ diff --git a/apps/www/src/client/pumple-frames/frame-01.png b/apps/www/src/client/pumple-frames/frame-01.png new file mode 100644 index 0000000..831d02b Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-01.png differ diff --git a/apps/www/src/client/pumple-frames/frame-02.png b/apps/www/src/client/pumple-frames/frame-02.png new file mode 100644 index 0000000..b32411c Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-02.png differ diff --git a/apps/www/src/client/pumple-frames/frame-03.png b/apps/www/src/client/pumple-frames/frame-03.png new file mode 100644 index 0000000..32f0853 Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-03.png differ diff --git a/apps/www/src/client/pumple-frames/frame-04.png b/apps/www/src/client/pumple-frames/frame-04.png new file mode 100644 index 0000000..1e9a95c Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-04.png differ diff --git a/apps/www/src/client/pumple-frames/frame-05.png b/apps/www/src/client/pumple-frames/frame-05.png new file mode 100644 index 0000000..c73ed17 Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-05.png differ diff --git a/apps/www/src/client/pumple-frames/frame-06.png b/apps/www/src/client/pumple-frames/frame-06.png new file mode 100644 index 0000000..50f86bb Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-06.png differ diff --git a/apps/www/src/client/pumple-frames/frame-07.png b/apps/www/src/client/pumple-frames/frame-07.png new file mode 100644 index 0000000..bf1c8db Binary files /dev/null and b/apps/www/src/client/pumple-frames/frame-07.png differ