diff --git a/client/package-lock.json b/client/package-lock.json index 990f5213..531982f4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,7 +15,7 @@ "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^12.23.24", + "framer-motion": "^12.29.0", "is-inside-container": "^1.0.0", "lucide-react": "^0.516.0", "next": "15.4.11", @@ -3679,13 +3679,13 @@ } }, "node_modules/framer-motion": { - "version": "12.23.24", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", - "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "version": "12.29.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.0.tgz", + "integrity": "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.23", - "motion-utils": "^12.23.6", + "motion-dom": "^12.29.0", + "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5002,18 +5002,18 @@ } }, "node_modules/motion-dom": { - "version": "12.23.23", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", - "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "version": "12.29.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.0.tgz", + "integrity": "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.6" + "motion-utils": "^12.27.2" } }, "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "version": "12.27.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.27.2.tgz", + "integrity": "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q==", "license": "MIT" }, "node_modules/ms": { diff --git a/client/package.json b/client/package.json index 6e09f5ab..90bee271 100644 --- a/client/package.json +++ b/client/package.json @@ -25,7 +25,7 @@ "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^12.23.24", + "framer-motion": "^12.29.0", "is-inside-container": "^1.0.0", "lucide-react": "^0.516.0", "next": "15.4.11", diff --git a/client/public/explosions/explosions/samj_cartoon_explosion.gif b/client/public/explosions/explosions/samj_cartoon_explosion.gif new file mode 100644 index 00000000..534ce70f Binary files /dev/null and b/client/public/explosions/explosions/samj_cartoon_explosion.gif differ diff --git a/client/public/explosions/samj_cartoon_explosion.gif b/client/public/explosions/samj_cartoon_explosion.gif new file mode 100644 index 00000000..534ce70f Binary files /dev/null and b/client/public/explosions/samj_cartoon_explosion.gif differ diff --git a/client/public/sfx/xplsion_0.mp3 b/client/public/sfx/xplsion_0.mp3 new file mode 100644 index 00000000..850bfd37 Binary files /dev/null and b/client/public/sfx/xplsion_0.mp3 differ diff --git a/client/public/sfx/xplsion_1.mp3 b/client/public/sfx/xplsion_1.mp3 new file mode 100644 index 00000000..40fce2a9 Binary files /dev/null and b/client/public/sfx/xplsion_1.mp3 differ diff --git a/client/public/sfx/xplsion_2.mp3 b/client/public/sfx/xplsion_2.mp3 new file mode 100644 index 00000000..1fc754d5 Binary files /dev/null and b/client/public/sfx/xplsion_2.mp3 differ diff --git a/client/public/sfx/xplsion_3.mp3 b/client/public/sfx/xplsion_3.mp3 new file mode 100644 index 00000000..689ca122 Binary files /dev/null and b/client/public/sfx/xplsion_3.mp3 differ diff --git a/client/src/components/ui/Crater.tsx b/client/src/components/ui/Crater.tsx new file mode 100644 index 00000000..48d457fc --- /dev/null +++ b/client/src/components/ui/Crater.tsx @@ -0,0 +1,172 @@ +import React, { useMemo } from "react"; + +interface CraterProps { + size?: number; + intensity?: number; +} + +/** + * Generates an irregular crater polygon + */ +function generateCraterShape(baseRadius: number, points: number = 10): string { + const coords: string[] = []; + + for (let i = 0; i < points; i++) { + const angle = (i / points) * Math.PI * 2; + const radius = baseRadius * (0.75 + Math.random() * 0.3); + const x = 50 + Math.cos(angle) * radius; + const y = 50 + Math.sin(angle) * radius; + coords.push(`${x.toFixed(1)},${y.toFixed(1)}`); + } + + return coords.join(" "); +} + +/** + * Generates a crack as a polygon shape (actual gap in ground) + * Returns points for a wedge-shaped fissure + */ +function generateCrackFissure( + angle: number, + startRadius: number, + length: number, +): string { + // Crack is a tapered wedge shape - wide at crater, sharp point at end + const widthAtStart = 7 + Math.random() * 5; // Bit skinnier (7-12) + const widthAtEnd = 0.5 + Math.random() * 1; // Sharp point at tip (0.5-1.5) + + // Calculate perpendicular angle for width + const perpAngle = angle + Math.PI / 2; + + // Points along the crack with some jaggedness + const segments = 3; + const points: Array<{ x: number; y: number }> = []; + const pointsBack: Array<{ x: number; y: number }> = []; + + let currentAngle = angle; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + // Start from inside crater and extend outward + const radius = startRadius + length * t; + const width = widthAtStart + (widthAtEnd - widthAtStart) * t; + + // Add jaggedness to angle + if (i > 0 && i < segments) { + currentAngle += (Math.random() - 0.5) * 0.3; + } + + const centerX = 50 + Math.cos(currentAngle) * radius; + const centerY = 50 + Math.sin(currentAngle) * radius; + + // Offset perpendicular for width + const offsetX = (Math.cos(perpAngle) * width) / 2; + const offsetY = (Math.sin(perpAngle) * width) / 2; + + // Add random jaggedness to edges - less at the tip for sharp point + const jagAmount = 4 * (1 - t * 0.8); // More jagged at start, smooth at tip + const jag1 = (Math.random() - 0.5) * jagAmount; + const jag2 = (Math.random() - 0.5) * jagAmount; + + points.push({ + x: centerX + offsetX + jag1, + y: centerY + offsetY + jag1, + }); + pointsBack.unshift({ + x: centerX - offsetX + jag2, + y: centerY - offsetY + jag2, + }); + } + + // Combine into polygon + const allPoints = [...points, ...pointsBack]; + return allPoints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); +} + +/** + * Crater with fissure-style cracks (actual gaps, not lines) + */ +export const Crater = React.memo(function Crater({ + size = 100, + intensity = 1, +}: CraterProps) { + const uniqueId = useMemo(() => Math.random().toString(36).substr(2, 9), []); + + // Crater shapes + const outerCrater = useMemo(() => generateCraterShape(22, 12), []); + const innerCrater = useMemo(() => generateCraterShape(14, 10), []); + const deepCrater = useMemo(() => generateCraterShape(8, 8), []); + + // Generate 2-4 crack fissures (reduced for performance) + const fissures = useMemo(() => { + const count = 2 + Math.floor(Math.random() * 3); + const result: Array<{ points: string }> = []; + + for (let i = 0; i < count; i++) { + const baseAngle = (i / count) * Math.PI * 2; + const angle = baseAngle + (Math.random() - 0.5) * 0.8; + const length = 20 + Math.random() * 18; + + result.push({ + points: generateCrackFissure(angle, 14, length), + }); + } + + return result; + }, []); + + // Colors + const voidColor = `rgba(0, 0, 0, ${intensity})`; + const deepColor = `rgba(8, 5, 2, ${0.95 * intensity})`; + const craterColor = `rgba(20, 15, 8, ${0.9 * intensity})`; + const rimColor = `rgba(45, 35, 25, ${0.7 * intensity})`; + + return ( + + + + + + + + + + + {/* Crack fissures - simplified to single polygon each */} + {fissures.map((fissure, i) => ( + + ))} + + {/* Outer crater rim */} + + + {/* Main crater */} + + + {/* Inner crater layer */} + + + {/* Deepest void */} + + + {/* Crater rim edge */} + + + ); +}); diff --git a/client/src/components/ui/DebrisBurst.tsx b/client/src/components/ui/DebrisBurst.tsx new file mode 100644 index 00000000..0210e880 --- /dev/null +++ b/client/src/components/ui/DebrisBurst.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; + +type Debris = { + id: number; + x: number; + y: number; + vx: number; + vy: number; + rot: number; + vr: number; + size: number; + life: number; + maxLife: number; +}; + +type Props = { + x: number; + y: number; + count?: number; + power?: number; + spreadDeg?: number; + gravity?: number; + groundY?: number; + bounce?: number; + onDone?: () => void; +}; + +// Use React.memo to prevent unnecessary re-renders +export const DebrisBurst = React.memo(function DebrisBurst({ + x, + y, + count = 8, // Reduced default count + power = 450, + spreadDeg = 360, + gravity = 1200, + groundY, + bounce = 0.3, + onDone, +}: Props) { + const containerRef = useRef(null); + const debrisRef = useRef([]); + const rafRef = useRef(null); + const lastT = useRef(0); + + // Generate initial debris - memoized + const initial = useMemo(() => { + const arr: Debris[] = []; + const spread = (spreadDeg * Math.PI) / 180; + + for (let i = 0; i < count; i++) { + const angle = + spreadDeg === 360 + ? Math.random() * Math.PI * 2 + : (Math.random() - 0.5) * spread; + + const speed = power * (0.5 + Math.random() * 0.5); + const vx = Math.cos(angle) * speed; + const vy = + -Math.abs(Math.sin(angle) * speed) * (0.7 + Math.random() * 0.3); + const size = 6 + Math.random() * 10; + const maxLife = 500 + Math.random() * 400; // Shorter lifetime + + arr.push({ + id: i, + x: 0, + y: 0, + vx, + vy, + rot: Math.random() * 360, + vr: (Math.random() - 0.5) * 600, + size, + life: maxLife, + maxLife, + }); + } + return arr; + }, [count, power, spreadDeg]); + + // Animation step - using refs to avoid re-renders + const step = useCallback( + (t: number) => { + const dt = Math.min(0.04, (t - lastT.current) / 1000); + lastT.current = t; + + const container = containerRef.current; + if (!container) return; + + const children = container.children; + let anyAlive = false; + + for (let i = 0; i < debrisRef.current.length; i++) { + const d = debrisRef.current[i]; + if (d.life <= 0) continue; + + d.life -= dt * 1000; + if (d.life <= 0) { + (children[i] as HTMLElement).style.display = "none"; + continue; + } + + anyAlive = true; + + // Physics + d.vx *= 1 - 0.2 * dt; + d.vy += gravity * dt; + d.x += d.vx * dt; + d.y += d.vy * dt; + d.rot += d.vr * dt; + + // Ground bounce + if (groundY !== undefined && y + d.y > groundY) { + d.y = groundY - y; + d.vy = -d.vy * bounce; + d.vx *= 0.7; + } + + // Update DOM directly (no React re-render) + const el = children[i] as HTMLElement; + const alpha = Math.max(0, d.life / d.maxLife); + el.style.transform = `translate3d(${d.x}px, ${d.y}px, 0) rotate(${d.rot}deg)`; + el.style.opacity = String(alpha); + } + + if (anyAlive) { + rafRef.current = requestAnimationFrame(step); + } else { + onDone?.(); + } + }, + [gravity, groundY, bounce, y, onDone], + ); + + // Set up animation + useEffect(() => { + debrisRef.current = initial.map((d) => ({ ...d })); + lastT.current = performance.now(); + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [initial, step]); + + return ( +
+ {initial.map((d) => ( + + ))} +
+ ); +}); diff --git a/client/src/components/ui/Explosion.tsx b/client/src/components/ui/Explosion.tsx new file mode 100644 index 00000000..78679785 --- /dev/null +++ b/client/src/components/ui/Explosion.tsx @@ -0,0 +1,95 @@ +import Image from "next/image"; +import React, { useEffect, useRef, useState } from "react"; + +import { ExplosionPosition } from "../../hooks/useExplosions"; +import { Crater } from "./Crater"; +import { DebrisBurst } from "./DebrisBurst"; +import { Smoke } from "./Smoke"; + +interface ExplosionProps { + explosion: ExplosionPosition; +} + +/** + * Renders a single explosion at a specific position. + * Position is defined as a percentage of the parent container. + */ +export const Explosion = React.memo(function Explosion({ + explosion, +}: ExplosionProps) { + const containerRef = useRef(null); + const [debrisPosition, setDebrisPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + // Convert percentage position to pixel coordinates for DebrisBurst + useEffect(() => { + if (!containerRef.current) return; + const container = containerRef.current.closest( + '[class*="relative"]', + ) as HTMLElement; + if (!container) { + // Fallback: use window if no relative container found + const x = (explosion.x / 100) * window.innerWidth; + const y = (explosion.y / 100) * window.innerHeight; + setDebrisPosition({ x, y }); + return; + } + + const rect = container.getBoundingClientRect(); + const x = rect.left + (explosion.x / 100) * rect.width; + const y = rect.top + (explosion.y / 100) * rect.height; + setDebrisPosition({ x, y }); + }, [explosion.x, explosion.y]); + + return ( +
+ {/* SVG Crater with depth shading */} +
+ +
+ {/* Physics-based debris burst */} + {debrisPosition && ( + + )} + {/* The actual explosion GIF */} +
+ Explosion +
+ {/* Rising smoke effect */} + +
+ ); +}); diff --git a/client/src/components/ui/ExplosionContainer.tsx b/client/src/components/ui/ExplosionContainer.tsx new file mode 100644 index 00000000..af8d590a --- /dev/null +++ b/client/src/components/ui/ExplosionContainer.tsx @@ -0,0 +1,24 @@ +import { useExplosions } from "../../hooks/useExplosions"; +import { Explosion } from "./Explosion"; + +/** + * Container component that renders all active explosions. + * Handles the explosion state management via the useExplosions hook. + */ +export function ExplosionContainer() { + const { explosions } = useExplosions(); + + return ( + <> + {explosions.map((explosion) => ( + + ))} + + ); +} + +/** + * Hook to access explosion trigger function from parent components. + * Use this with ExplosionContainer to trigger explosions from anywhere. + */ +export { useExplosions }; diff --git a/client/src/components/ui/ExplosionEffects.tsx b/client/src/components/ui/ExplosionEffects.tsx new file mode 100644 index 00000000..a4386aee --- /dev/null +++ b/client/src/components/ui/ExplosionEffects.tsx @@ -0,0 +1,41 @@ +import { DebrisBurst } from "@/components/ui/DebrisBurst"; +import { Explosion } from "@/components/ui/Explosion"; +import { ExplosionPosition } from "@/hooks/useExplosions"; +import { ClickDebris } from "@/hooks/usePageExplosions"; + +interface ExplosionEffectsProps { + explosions: ExplosionPosition[]; + clickDebris: ClickDebris[]; +} + +/** + * Renders all active explosions and debris bursts. + * Extracts duplicated rendering logic from pages. + */ +export function ExplosionEffects({ + explosions, + clickDebris, +}: ExplosionEffectsProps) { + return ( + <> + {/* Render explosions */} + {explosions.map((explosion) => ( + + ))} + {/* Render DebrisBurst for clicks */} + {clickDebris.map((debris) => ( + + ))} + + ); +} + diff --git a/client/src/components/ui/Smoke.tsx b/client/src/components/ui/Smoke.tsx new file mode 100644 index 00000000..adc6241e --- /dev/null +++ b/client/src/components/ui/Smoke.tsx @@ -0,0 +1,179 @@ +import { motion } from "framer-motion"; +import React, { useMemo } from "react"; + +interface SmokeProps { + x: number; // percentage position + y: number; // percentage position + duration?: number; // how long smoke lasts (ms) +} + +/** + * Optimized SVG smoke effect + * Uses shared filters and reduced complexity for better performance + */ +export const Smoke = React.memo(function Smoke({ + x, + y, + duration = 2000, +}: SmokeProps) { + const uniqueId = useMemo(() => Math.random().toString(36).substr(2, 9), []); + + // Generate fewer smoke puffs (5 instead of 8) + const smokePuffs = useMemo(() => { + const puffs = []; + const count = 5; + + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; + const distance = 45 + Math.random() * 35; + + puffs.push({ + id: i, + endX: Math.cos(angle) * distance, + endY: Math.sin(angle) * distance - 15, + delay: i * 0.03, + }); + } + return puffs; + }, []); + + return ( +
+ {/* Shared SVG defs - filters defined once */} + + + {/* Single optimized turbulence filter */} + + + + + + + {/* Shared gradient */} + + + + + + + + + {/* Central expanding smoke - uses CSS blur for performance */} + + + {/* Smoke puffs - share single filter */} + {smokePuffs.map((puff) => ( + + + + + + ))} + + {/* Single rising wisp - CSS blur only */} + +
+ ); +}); diff --git a/client/src/hooks/useExplosions.ts b/client/src/hooks/useExplosions.ts new file mode 100644 index 00000000..09844632 --- /dev/null +++ b/client/src/hooks/useExplosions.ts @@ -0,0 +1,113 @@ +import { useCallback, useState } from "react"; + +/** + * Plays a random explosion sound effect. + */ +function playExplosionSound(): void { + const soundIndex = Math.floor(Math.random() * 4); // 0-3 for xplsion_0 to xplsion_3 + const audio = new Audio(`/sfx/xplsion_${soundIndex}.mp3`); + audio.volume = 0.1; // Set volume to 10% to avoid being too loud + audio.play().catch((error) => { + // Handle autoplay restrictions gracefully + console.warn("Could not play explosion sound:", error); + }); +} + +/** + * Position of a single explosion within a container. + * Coordinates are percentages (0-100) relative to container size. + */ +export type ExplosionPosition = { + id: string; + x: number; // Percentage (0-100) + y: number; // Percentage (0-100) + createdAt: number; // Timestamp for cleanup +}; + +/** + * Configuration for explosion spawning behavior. + */ +export type ExplosionConfig = { + count?: number; // Number of explosions to spawn (default: 1) + minDelay?: number; // Minimum delay between explosions in ms (default: 0) + maxDelay?: number; // Maximum delay between explosions in ms (default: 100) + duration?: number; // How long explosions stay visible in ms (default: 1000) + playSound?: boolean; // Whether to play sound effects (default: true) + position?: { x: number; y: number }; // Specific position (percentage 0-100), if not provided uses random +}; + +/** + * Custom hook to manage explosion spawning. + * Provides state and functions to trigger explosions. + */ +export function useExplosions() { + const [explosions, setExplosions] = useState([]); + + const triggerExplosions = useCallback( + (config: ExplosionConfig = {}, containerBounds?: DOMRect | null) => { + const { + count = 1, + minDelay = 0, + maxDelay = 100, + duration = 3000, + playSound = true, + position, // Optional fixed position + } = config; + + // Generate explosion positions + const now = Date.now(); + + for (let i = 0; i < count; i++) { + let x: number; + let y: number; + + if (position) { + // Use specific position provided + x = position.x; + y = position.y; + } else if (containerBounds) { + // Random position within container bounds (10% margin) + const margin = 10; + x = margin + Math.random() * (100 - margin * 2); + y = margin + Math.random() * (100 - margin * 2); + } else { + // Random position across full area + x = Math.random() * 100; + y = Math.random() * 100; + } + + const delay = minDelay + Math.random() * (maxDelay - minDelay); + + setTimeout(() => { + const explosionId = `${now}-${i}-${Math.random()}`; + const explosion: ExplosionPosition = { + id: explosionId, + x, + y, + createdAt: Date.now(), + }; + + setExplosions((prev) => [...prev, explosion]); + + // Play sound effect + if (playSound) { + playExplosionSound(); + } + + // Clean up after duration + setTimeout(() => { + setExplosions((prev) => + prev.filter((exp) => exp.id !== explosionId), + ); + }, duration); + }, delay); + } + }, + [], + ); + + return { + explosions, + triggerExplosions, + }; +} diff --git a/client/src/hooks/usePageExplosions.ts b/client/src/hooks/usePageExplosions.ts new file mode 100644 index 00000000..fe63f626 --- /dev/null +++ b/client/src/hooks/usePageExplosions.ts @@ -0,0 +1,84 @@ +import { useCallback, useRef, useState } from "react"; + +import { useExplosions } from "@/hooks/useExplosions"; + +// Max concurrent debris bursts to prevent lag +const MAX_DEBRIS = 5; + +export type ClickDebris = { + id: number; + x: number; + y: number; +}; + +/** + * Hook to manage page-level explosion interactions. + * Extracts common explosion logic used across multiple pages. + */ +export function usePageExplosions() { + const { explosions, triggerExplosions: baseTrigger } = useExplosions(); + const containerRef = useRef(null); + const [clickDebris, setClickDebris] = useState([]); + const lastClickTime = useRef(0); + const debrisTimeouts = useRef>>(new Set()); + + const handlePageClick = useCallback( + (e: React.MouseEvent) => { + if (!containerRef.current) return; + + // Throttle clicks - 100ms minimum between clicks + const now = Date.now(); + if (now - lastClickTime.current < 100) return; + lastClickTime.current = now; + + const rect = containerRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + // Get absolute pixel position for DebrisBurst + const absoluteX = e.clientX; + const absoluteY = e.clientY; + + // Create explosion at click position + baseTrigger({ + count: 1, + minDelay: 0, + maxDelay: 0, + duration: 1500, + playSound: true, + position: { x, y }, + }); + + // Add DebrisBurst for click (limit max concurrent) + const debrisId = now; + setClickDebris((prev) => { + const updated = [...prev, { id: debrisId, x: absoluteX, y: absoluteY }]; + return updated.slice(-MAX_DEBRIS); + }); + + // Remove after animation completes + const timeout = setTimeout(() => { + setClickDebris((prev) => prev.filter((d) => d.id !== debrisId)); + debrisTimeouts.current.delete(timeout); + }, 1500); + debrisTimeouts.current.add(timeout); + }, + [baseTrigger], + ); + + // Cleanup timeouts on unmount + const cleanup = useCallback(() => { + debrisTimeouts.current.forEach((t) => clearTimeout(t)); + debrisTimeouts.current.clear(); + }, []); + + return { + containerRef, + handlePageClick, + explosions, + clickDebris, + triggerExplosions: baseTrigger, + cleanup, + }; +} + diff --git a/client/src/pages/about.tsx b/client/src/pages/about.tsx index 996a5691..4874bbb5 100644 --- a/client/src/pages/about.tsx +++ b/client/src/pages/about.tsx @@ -1,9 +1,67 @@ +import { motion } from "framer-motion"; import Image from "next/image"; +import { useCallback, useRef, useState } from "react"; import { ApiMember, useCommittee } from "@/hooks/useCommittee"; +import { DebrisBurst } from "../components/ui/DebrisBurst"; +import { Explosion } from "../components/ui/Explosion"; +import { useExplosions } from "../hooks/useExplosions"; + +// Max concurrent debris bursts to prevent lag +const MAX_DEBRIS = 5; + export default function AboutPage() { const { data: committee, isPending, error, isError } = useCommittee(); + const { explosions, triggerExplosions } = useExplosions(); + const containerRef = useRef(null); + const [clickDebris, setClickDebris] = useState< + Array<{ id: number; x: number; y: number }> + >([]); + const lastClickTime = useRef(0); + + const handlePageClick = useCallback( + (e: React.MouseEvent) => { + if (!containerRef.current) return; + + // Throttle clicks - 100ms minimum between clicks + const now = Date.now(); + if (now - lastClickTime.current < 100) return; + lastClickTime.current = now; + + const rect = containerRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + // Get absolute pixel position for DebrisBurst + const absoluteX = e.clientX; + const absoluteY = e.clientY; + + // Create explosion at click position + triggerExplosions({ + count: 1, + minDelay: 0, + maxDelay: 0, + duration: 1500, + playSound: true, + position: { x, y }, // Pass click position + }); + + // Add DebrisBurst for click (limit max concurrent) + const debrisId = now; + setClickDebris((prev) => { + const updated = [...prev, { id: debrisId, x: absoluteX, y: absoluteY }]; + // Keep only the most recent MAX_DEBRIS + return updated.slice(-MAX_DEBRIS); + }); + + // Remove after animation completes + setTimeout(() => { + setClickDebris((prev) => prev.filter((d) => d.id !== debrisId)); + }, 1500); + }, + [triggerExplosions], + ); const topRow: ApiMember[] = []; const bottomRow: ApiMember[] = []; @@ -86,14 +144,35 @@ export default function AboutPage() { : "Failed to load Committee Members."; return ( - <> + + {/* Render explosions */} + {explosions.map((explosion) => ( + + ))} + {/* Render DebrisBurst for clicks */} + {clickDebris.map((debris) => ( + + ))} {about} -
+

{errorMessage}

-
- + +
); } else { for (let i = 0; i < 8; i++) { @@ -106,7 +185,28 @@ export default function AboutPage() { } return ( -
+ + {/* Render explosions */} + {explosions.map((explosion) => ( + + ))} + {/* Render DebrisBurst for clicks */} + {clickDebris.map((debris) => ( + + ))} {about} {/* Portraits Section - DARK - Full Width */}
@@ -176,6 +276,6 @@ export default function AboutPage() {
-
+ ); } diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index cefdb53a..fef08616 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,5 +1,7 @@ +import { motion } from "framer-motion"; import Image from "next/image"; import Link from "next/link"; +import { useCallback, useRef, useState } from "react"; import EventCarousel from "@/components/ui/eventCarousel"; import { @@ -9,8 +11,86 @@ import { import { placeholderEvents, placeholderGames } from "@/placeholderData"; import { Button } from "../components/ui/button"; +import { DebrisBurst } from "../components/ui/DebrisBurst"; +import { Explosion } from "../components/ui/Explosion"; +import { useExplosions } from "../hooks/useExplosions"; + +// Max concurrent debris bursts to prevent lag +const MAX_DEBRIS = 5; export default function Landing() { + const { explosions, triggerExplosions } = useExplosions(); + const containerRef = useRef(null); + const [clickDebris, setClickDebris] = useState< + Array<{ id: number; x: number; y: number }> + >([]); + const lastClickTime = useRef(0); + const [isShaking, setIsShaking] = useState(false); + + const handleBombClick = () => { + // Trigger a massive explosion across the whole page + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + triggerExplosions( + { + count: 10, // Lots of explosions! + minDelay: 0, + maxDelay: 500, // Stagger them over half a second + duration: 2000, + playSound: true, + }, + rect, + ); + + // Trigger screen shake + setIsShaking(true); + setTimeout(() => setIsShaking(false), 400); // Shake for 400ms + }; + + const handlePageClick = useCallback( + (e: React.MouseEvent) => { + if (!containerRef.current) return; + + // Throttle clicks - 100ms minimum between clicks + const now = Date.now(); + if (now - lastClickTime.current < 100) return; + lastClickTime.current = now; + + const rect = containerRef.current.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 100; + const y = ((e.clientY - rect.top) / rect.height) * 100; + + // Get absolute pixel position for DebrisBurst + const absoluteX = e.clientX; + const absoluteY = e.clientY; + + // Create explosion at click position + triggerExplosions({ + count: 1, + minDelay: 0, + maxDelay: 0, + duration: 1500, + playSound: true, + position: { x, y }, // Pass click position + }); + + // Add DebrisBurst for click (limit max concurrent) + const debrisId = now; + setClickDebris((prev) => { + const updated = [...prev, { id: debrisId, x: absoluteX, y: absoluteY }]; + // Keep only the most recent MAX_DEBRIS + return updated.slice(-MAX_DEBRIS); + }); + + // Remove after animation completes + setTimeout(() => { + setClickDebris((prev) => prev.filter((d) => d.id !== debrisId)); + }, 1500); + }, + [triggerExplosions], + ); + const gameLogoImages = [ { url: "/godot.png", alt: "Godot Logo", position: "start" }, { url: "/unity-logo.png", alt: "Unity Logo", position: "end" }, @@ -61,7 +141,37 @@ export default function Landing() { ]; return ( -
+ + {/* Render explosions */} + {explosions.map((explosion) => ( + + ))} + {/* Render DebrisBurst for clicks */} + {clickDebris.map((debris) => ( + + ))}
@@ -94,8 +204,9 @@ export default function Landing() { src="/bomb.png" width={96} height={156} - alt="placeholder" - className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 [image-rendering:pixelated]" + alt="Bomb - click to explode!" + className="absolute bottom-0 left-0 h-auto w-[20%] -translate-x-1/4 -translate-y-4 cursor-pointer transition-transform [image-rendering:pixelated] hover:scale-110" + onClick={handleBombClick} />
@@ -197,6 +308,6 @@ export default function Landing() {
- + ); } diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css index e6445070..0e3434ea 100644 --- a/client/src/styles/globals.css +++ b/client/src/styles/globals.css @@ -58,3 +58,65 @@ @apply bg-background text-foreground; } } + +@keyframes crater-fade { + 0% { + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@keyframes crater-expand { + 0% { + transform: translate(-50%, -50%) scale(0.15); + opacity: 0; + } + 20% { + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } +} + +@keyframes crater-punch { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes fissure-open { + 0% { + transform: scale(0); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.debris-chunk { + position: absolute; + left: 0; + top: 0; + transform-origin: center; + border-radius: 2px; + background: linear-gradient(135deg, #888 0%, #444 100%); + contain: layout style; +}