@fivemepeople/seasonal-effects-v2
Version:
Coleção de componentes React com efeitos sazonais e animações
805 lines (701 loc) • 21.6 kB
text/typescript
"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;