UNPKG

@fivemepeople/seasonal-effects-v2

Version:

Coleção de componentes React com efeitos sazonais e animações

805 lines (701 loc) 21.6 kB
"use client"; import React, { useEffect, useRef, useCallback } from "react"; // Colors inspired by the original fireworks const COLORS = { Red: "#ff0043", Green: "#14fc56", Blue: "#1e7fff", Purple: "#e60aff", Gold: "#ffbf36", White: "#ffffff", }; const COLOR_NAMES = Object.keys(COLORS); const COLOR_CODES = COLOR_NAMES.map( (colorName) => COLORS[colorName as keyof typeof COLORS] ); // Special invisible color for transitions const INVISIBLE = "_INVISIBLE_"; const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE]; // Constants for physics const GRAVITY = 0.9; const PI_2 = Math.PI * 2; const PI_HALF = Math.PI * 0.5; // Utility functions const random = (min: number, max: number) => Math.random() * (max - min) + min; const randomInt = (min: number, max: number) => Math.floor(random(min, max)); const randomColor = () => COLOR_CODES[randomInt(0, COLOR_CODES.length)]; // Create particle arc helper const createParticleArc = ( start: number, arcLength: number, count: number, randomness: number, particleFactory: (angle: number) => void ) => { const angleDelta = arcLength / count; const end = start + arcLength - angleDelta * 0.5; if (end > start) { for (let angle = start; angle < end; angle += angleDelta) { particleFactory(angle + Math.random() * angleDelta * randomness); } } }; // Create spherical burst helper const createBurst = ( count: number, particleFactory: (angle: number, speedMult: number) => void, startAngle = 0, arcLength = PI_2 ) => { const R = 0.5 * Math.sqrt(count / Math.PI); const C = 2 * R * Math.PI; const C_HALF = C / 2; for (let i = 0; i <= C_HALF; i++) { const ringAngle = (i / C_HALF) * PI_HALF; const ringSize = Math.cos(ringAngle); const partsPerFullRing = C * ringSize; const partsPerArc = partsPerFullRing * (arcLength / PI_2); const angleInc = PI_2 / partsPerFullRing; const angleOffset = Math.random() * angleInc + startAngle; const maxRandomAngleOffset = angleInc * 0.33; for (let j = 0; j < partsPerArc; j++) { const randomAngleOffset = Math.random() * maxRandomAngleOffset; const angle = angleInc * j + angleOffset + randomAngleOffset; particleFactory(angle, ringSize); } } }; // Object pools for performance const createParticleCollection = () => { const collection: { [key: string]: any[] } = {}; COLOR_CODES_W_INVIS.forEach((color) => { collection[color] = []; }); return collection; }; // Particle classes class Star { x: number; y: number; prevX: number; prevY: number; speedX: number; speedY: number; life: number; fullLife: number; color: string; sparkFreq: number; sparkTimer: number; sparkColor: string; sparkSpeed: number; sparkLife: number; sparkLifeVariation: number; visible: boolean; heavy: boolean; strobe: boolean; strobeFreq: number; transitionTime: number; secondColor?: string; colorChanged: boolean; spinAngle: number; spinSpeed: number; spinRadius: number; updateFrame: number; onDeath?: (star: Star) => void; static drawWidth = 4.4; static airDrag = 0.98; static airDragHeavy = 0.992; static active = createParticleCollection(); static _pool: Star[] = []; constructor( x: number, y: number, color: string, angle: number, speed: number, life: number, speedOffX = 0, speedOffY = 0 ) { this.x = x; this.y = y; this.prevX = x; this.prevY = y; this.speedX = Math.sin(angle) * speed + speedOffX; this.speedY = Math.cos(angle) * speed + speedOffY; this.life = life; this.fullLife = life; this.color = color; this.sparkFreq = 0; this.sparkTimer = 0; this.sparkColor = color; this.sparkSpeed = 1; this.sparkLife = 750; this.sparkLifeVariation = 0.25; this.visible = true; this.heavy = false; this.strobe = false; this.strobeFreq = 60; this.transitionTime = 0; this.colorChanged = false; this.spinAngle = Math.random() * PI_2; this.spinSpeed = 0.8; this.spinRadius = 0; this.updateFrame = 0; } static add( x: number, y: number, color: string, angle: number, speed: number, life: number, speedOffX = 0, speedOffY = 0 ) { const instance = this._pool.pop() || new Star(x, y, color, angle, speed, life, speedOffX, speedOffY); // Reset instance instance.x = x; instance.y = y; instance.prevX = x; instance.prevY = y; instance.color = color; instance.speedX = Math.sin(angle) * speed + speedOffX; instance.speedY = Math.cos(angle) * speed + speedOffY; instance.life = life; instance.fullLife = life; instance.visible = true; instance.heavy = false; instance.sparkFreq = 0; instance.sparkTimer = 0; instance.sparkColor = color; instance.sparkSpeed = 1; instance.sparkLife = 750; instance.sparkLifeVariation = 0.25; instance.strobe = false; instance.transitionTime = 0; instance.secondColor = undefined; instance.colorChanged = false; instance.spinAngle = Math.random() * PI_2; instance.spinRadius = 0; instance.onDeath = undefined; this.active[color].push(instance); return instance; } static returnInstance(instance: Star) { instance.onDeath?.(instance); instance.onDeath = undefined; instance.secondColor = undefined; this._pool.push(instance); } update(timeStep: number, sparks: Spark[]) { this.life -= timeStep; if (this.life <= 0) return false; const burnRate = (this.life / this.fullLife) ** 0.5; const burnRateInverse = 1 - burnRate; this.prevX = this.x; this.prevY = this.y; this.x += this.speedX * (timeStep / 16); this.y += this.speedY * (timeStep / 16); // Air drag const drag = this.heavy ? 0.992 : 0.98; this.speedX *= drag ** (timeStep / 16); this.speedY *= drag ** (timeStep / 16); this.speedY += (GRAVITY * timeStep) / 1000; // Spin effects for comets if (this.spinRadius > 0) { this.spinAngle += (this.spinSpeed * timeStep) / 16; this.x += (Math.sin(this.spinAngle) * this.spinRadius * timeStep) / 16; this.y += (Math.cos(this.spinAngle) * this.spinRadius * timeStep) / 16; } // Sparks if (this.sparkFreq > 0) { this.sparkTimer -= timeStep; while (this.sparkTimer < 0) { this.sparkTimer += this.sparkFreq * 0.75 + this.sparkFreq * burnRateInverse * 4; sparks.push( new Spark( this.x, this.y, this.sparkColor, Math.random() * PI_2, Math.random() * this.sparkSpeed * burnRate, this.sparkLife * 0.8 + Math.random() * this.sparkLife * this.sparkLifeVariation ) ); } } // Handle transitions if (this.life < this.transitionTime) { if (this.secondColor && !this.colorChanged) { this.colorChanged = true; // Move star to new color array const currentArray = Star.active[this.color]; const index = currentArray.indexOf(this); if (index > -1) { currentArray.splice(index, 1); this.color = this.secondColor; Star.active[this.secondColor].push(this); if (this.secondColor === INVISIBLE) { this.sparkFreq = 0; } } } if (this.strobe) { this.visible = Math.floor(this.life / this.strobeFreq) % 3 === 0; } } return true; } draw(ctx: CanvasRenderingContext2D) { if (!this.visible) return; ctx.strokeStyle = this.color; ctx.lineWidth = Star.drawWidth; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(this.prevX, this.prevY); ctx.stroke(); } } class Spark { x: number; y: number; prevX: number; prevY: number; speedX: number; speedY: number; life: number; fullLife: number; color: string; static drawWidth = 1; static airDrag = 0.9; static active = createParticleCollection(); static _pool: Spark[] = []; constructor( x: number, y: number, color: string, angle: number, speed: number, life: number ) { this.x = x; this.y = y; this.prevX = x; this.prevY = y; this.speedX = Math.sin(angle) * speed; this.speedY = Math.cos(angle) * speed; this.life = life; this.fullLife = life; this.color = color; } static add( x: number, y: number, color: string, angle: number, speed: number, life: number ) { const instance = this._pool.pop() || new Spark(x, y, color, angle, speed, life); instance.x = x; instance.y = y; instance.prevX = x; instance.prevY = y; instance.speedX = Math.sin(angle) * speed; instance.speedY = Math.cos(angle) * speed; instance.life = life; instance.fullLife = life; instance.color = color; this.active[color].push(instance); return instance; } static returnInstance(instance: Spark) { this._pool.push(instance); } update(timeStep: number) { this.life -= timeStep; if (this.life <= 0) return false; this.prevX = this.x; this.prevY = this.y; this.x += this.speedX * (timeStep / 16); this.y += this.speedY * (timeStep / 16); this.speedX *= 0.9 ** (timeStep / 16); this.speedY *= 0.9 ** (timeStep / 16); this.speedY += (GRAVITY * timeStep) / 1000; return true; } draw(ctx: CanvasRenderingContext2D) { const alpha = this.life / this.fullLife; ctx.globalAlpha = alpha; ctx.strokeStyle = this.color; ctx.lineWidth = Spark.drawWidth; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(this.prevX, this.prevY); ctx.stroke(); ctx.globalAlpha = 1; } } class BurstFlash { x: number; y: number; radius: number; static active: BurstFlash[] = []; static _pool: BurstFlash[] = []; constructor(x: number, y: number, radius: number) { this.x = x; this.y = y; this.radius = radius; } static add(x: number, y: number, radius: number) { const instance = this._pool.pop() || new BurstFlash(x, y, radius); instance.x = x; instance.y = y; instance.radius = radius; this.active.push(instance); return instance; } static returnInstance(instance: BurstFlash) { this._pool.push(instance); } } class Shell { x: number; y: number; targetX: number; targetY: number; speedX: number; speedY: number; life: number; color: string; size: number; type: string; exploded: boolean; comet?: Star; constructor(x: number, targetX: number, targetY: number) { this.x = x; this.y = window.innerHeight; this.targetX = targetX; this.targetY = targetY; // Trajetória mais lenta const distance = Math.sqrt((targetX - x) ** 2 + (targetY - this.y) ** 2); const launchVelocity = (distance * 0.026) ** 0.64; // Mais devagar this.speedX = ((targetX - x) / distance) * launchVelocity * 0.3; this.speedY = -launchVelocity; this.life = launchVelocity * 300; this.color = randomColor(); this.size = random(1.0, 1.2); this.type = this.getRandomType(); this.exploded = false; } getRandomType(): string { // Agora pode ser 'chrysanthemum' ou 'palm' const types = ["chrysanthemum", "palm"]; return types[randomInt(0, types.length)]; } update(timeStep: number): boolean { if (this.exploded) return false; this.life -= timeStep; this.x += this.speedX * (timeStep / 16); this.y += this.speedY * (timeStep / 16); this.speedY += (GRAVITY * timeStep) / 1000; const distanceToTarget = Math.sqrt( (this.targetX - this.x) ** 2 + (this.targetY - this.y) ** 2 ); if (distanceToTarget < 20 || this.life <= 0 || this.speedY > 0) { return false; } return true; } launch() { // Cobrinha maior this.comet = Star.add( this.x, this.y, this.color, Math.PI, Math.abs(this.speedY) * 1.5, // um pouco maior this.life ); this.comet.heavy = true; this.comet.spinRadius = random(0.65, 0.4); // aumenta o raio da cobrinha this.comet.sparkFreq = 32; this.comet.sparkLife = 320; this.comet.sparkLifeVariation = 3; this.comet.sparkSpeed = 0.5; this.comet.sparkColor = this.color; } draw(ctx: CanvasRenderingContext2D) { if (this.exploded) return; // Draw comet trail ctx.strokeStyle = this.color; ctx.lineWidth = 3; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(this.x - this.speedX * 3, this.y - this.speedY * 3); ctx.stroke(); // Draw comet head ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.x, this.y, 2, 0, PI_2); ctx.fill(); } explode(stars: Star[], sparks: Spark[]) { this.exploded = true; // Volta ao padrão de partículas e velocidade const starCount = 60 + randomInt(0, 40); const speed = 3 + this.size; switch (this.type) { case "chrysanthemum": this.createChrysanthemum(stars, sparks, starCount, speed); break; case "palm": this.createPalm(stars, sparks, starCount, speed); break; } // Criar efeito de flash this.createBurstFlash(sparks); } createChrysanthemum( stars: Star[], sparks: Spark[], count: number, speed: number ) { createBurst(count, (angle, speedMult) => { const star = Star.add( this.x, this.y, this.color, angle, speedMult * speed, 900 + random(-200, 200) ); if (Math.random() < 0.3) { star.sparkFreq = 200; star.sparkColor = COLORS.Gold; star.sparkSpeed = 0.5; star.sparkLife = 400; } stars.push(star); }); } createPalm(stars: Star[], sparks: Spark[], count: number, speed: number) { createBurst(Math.floor(count * 0.4), (angle, speedMult) => { const star = Star.add( this.x, this.y, this.color, angle, speedMult * speed, 1800 + random(-300, 300) ); star.sparkFreq = 80; star.sparkColor = COLORS.Gold; star.sparkSpeed = 0.8; star.sparkLife = 1400; star.sparkLifeVariation = 2; stars.push(star); }); } createBurstFlash(sparks: Spark[]) { // Create bright white flash BurstFlash.add(this.x, this.y, 40 + this.size * 10); for (let i = 0; i < 30; i++) { sparks.push( new Spark( this.x, this.y, COLORS.White, Math.random() * PI_2, random(0.5, 3), 150 + random(-30, 30) ) ); } } } const Fireworks2: React.FC = () => { const trailsCanvasRef = useRef<HTMLCanvasElement>(null); const mainCanvasRef = useRef<HTMLCanvasElement>(null); const animationFrameRef = useRef<number>(); const lastTimeRef = useRef<number>(0); const shellsRef = useRef<Shell[]>([]); const starsRef = useRef<Star[]>([]); const sparksRef = useRef<Spark[]>([]); const lastLaunchTimeRef = useRef<number>(0); const resizeCanvas = useCallback(() => { const trailsCanvas = trailsCanvasRef.current; const mainCanvas = mainCanvasRef.current; if (!trailsCanvas || !mainCanvas) return; // Garante que ocupa toda a tela sem scroll const width = window.innerWidth; const height = window.innerHeight; trailsCanvas.width = width; trailsCanvas.height = height; mainCanvas.width = width; mainCanvas.height = height; // Remove scrollbars do body document.body.style.overflow = "hidden"; document.body.style.margin = "0"; document.body.style.padding = "0"; }, []); const launchShell = useCallback(() => { const width = window.innerWidth; const height = window.innerHeight; const margin = 120; const x = random(margin, width - margin); const targetX = random(margin, width - margin); // Diminui altura máxima da explosão const targetY = random(height * 0.35, height * 0.45); const shell = new Shell(x, targetX, targetY); shell.launch(); shellsRef.current.push(shell); }, []); const update = useCallback( (currentTime: number) => { const trailsCanvas = trailsCanvasRef.current; const mainCanvas = mainCanvasRef.current; if (!trailsCanvas || !mainCanvas) return; const trailsCtx = trailsCanvas.getContext("2d"); const mainCtx = mainCanvas.getContext("2d"); if (!trailsCtx || !mainCtx) return; const deltaTime = currentTime - lastTimeRef.current || 16; lastTimeRef.current = currentTime; // Auto-launch shells if (currentTime - lastLaunchTimeRef.current > 1500) { launchShell(); lastLaunchTimeRef.current = currentTime; } // Clear main canvas mainCtx.clearRect(0, 0, mainCanvas.width, mainCanvas.height); // Add fade effect to trails canvas trailsCtx.globalCompositeOperation = "source-over"; trailsCtx.fillStyle = "rgba(0, 0, 0, 0.15)"; trailsCtx.fillRect(0, 0, trailsCanvas.width, trailsCanvas.height); // Draw burst flashes first while (BurstFlash.active.length > 0) { const flash = BurstFlash.active.pop()!; const gradient = trailsCtx.createRadialGradient( flash.x, flash.y, 0, flash.x, flash.y, flash.radius ); gradient.addColorStop(0.024, "rgba(255, 255, 255, 1)"); gradient.addColorStop(0.125, "rgba(255, 160, 20, 0.2)"); gradient.addColorStop(0.32, "rgba(255, 140, 20, 0.11)"); gradient.addColorStop(1, "rgba(255, 120, 20, 0)"); trailsCtx.fillStyle = gradient; trailsCtx.fillRect( flash.x - flash.radius, flash.y - flash.radius, flash.radius * 2, flash.radius * 2 ); BurstFlash.returnInstance(flash); } trailsCtx.globalCompositeOperation = "lighten"; // Update and draw shells for (let i = shellsRef.current.length - 1; i >= 0; i--) { const shell = shellsRef.current[i]; shell.draw(mainCtx); if (!shell.update(deltaTime)) { shell.explode(starsRef.current, sparksRef.current); shellsRef.current.splice(i, 1); } } // Update and draw stars by color COLOR_CODES_W_INVIS.forEach((color) => { const stars = Star.active[color]; trailsCtx.strokeStyle = color; trailsCtx.lineWidth = Star.drawWidth; trailsCtx.lineCap = "round"; for (let i = stars.length - 1; i >= 0; i--) { const star = stars[i]; if (color !== INVISIBLE) { star.draw(trailsCtx); } if (!star.update(deltaTime, sparksRef.current)) { stars.splice(i, 1); Star.returnInstance(star); } } }); // Update and draw sparks by color COLOR_CODES.forEach((color) => { const sparks = Spark.active[color]; trailsCtx.strokeStyle = color; trailsCtx.lineWidth = Spark.drawWidth; trailsCtx.lineCap = "round"; for (let i = sparks.length - 1; i >= 0; i--) { const spark = sparks[i]; spark.draw(trailsCtx); if (!spark.update(deltaTime)) { sparks.splice(i, 1); Spark.returnInstance(spark); } } }); animationFrameRef.current = requestAnimationFrame(update); }, [launchShell] ); useEffect(() => { resizeCanvas(); window.addEventListener("resize", resizeCanvas); // Start animation animationFrameRef.current = requestAnimationFrame(update); return () => { window.removeEventListener("resize", resizeCanvas); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [resizeCanvas, update]); return ( <div className="fixed inset-0 pointer-events-none" style={{ width: "100vw", height: "100vh", margin: 0, padding: 0, overflow: "hidden", }} > <canvas ref={trailsCanvasRef} className="absolute inset-0" style={{ mixBlendMode: "lighten", width: "100vw", height: "100vh", display: "block", }} /> <canvas ref={mainCanvasRef} className="absolute inset-0" style={{ mixBlendMode: "lighten", width: "100vw", height: "100vh", display: "block", }} /> </div> ); }; export default Fireworks2;