3291 lines
114 KiB
TypeScript
3291 lines
114 KiB
TypeScript
/**
|
|
* Background Themes - Canvas-based background effects for ComfyUI
|
|
*
|
|
* Includes: Stars, shooting stars, snowflakes, fireworks, mouse effects,
|
|
* New Year countdown, and finale celebrations.
|
|
*/
|
|
|
|
// @ts-ignore - ComfyUI external module
|
|
import { app } from "../../scripts/app.js";
|
|
import { getSetting, updateCache, initSettingsCache, loadSettingFromStorage, BACKGROUND_THEMES, COLOR_SCHEMES } from "./settings-cache";
|
|
|
|
// ============================================================================
|
|
// Type Definitions
|
|
// ============================================================================
|
|
|
|
interface StarEntity {
|
|
x: number;
|
|
y: number;
|
|
size?: number;
|
|
brightness?: number;
|
|
baseAlpha?: number;
|
|
twinkleSpeed?: number;
|
|
twinklePhase?: number;
|
|
layer?: string;
|
|
color?: string;
|
|
// Extended properties
|
|
twinkleOffset?: number;
|
|
twinkleOffset2?: number;
|
|
twinkleSpeedMod?: number;
|
|
baseOpacity?: number;
|
|
hasGlow?: boolean;
|
|
hasSpikes?: boolean;
|
|
}
|
|
|
|
interface ShootingStar {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
size?: number;
|
|
trail?: Array<{ x: number; y: number; alpha?: number; size?: number }>;
|
|
alpha?: number;
|
|
color?: string;
|
|
// Extended properties for enhanced drawing
|
|
length?: number;
|
|
life?: number;
|
|
decay?: number;
|
|
brightness?: number;
|
|
fragments?: Array<{ x: number; y: number; vx?: number; vy?: number; alpha?: number; size: number; life?: number }>;
|
|
lastFragmentTime?: number;
|
|
fragmentInterval?: number;
|
|
willFlare?: boolean;
|
|
flareIntensity?: number;
|
|
}
|
|
|
|
interface NebulaEntity {
|
|
x: number;
|
|
y: number;
|
|
size?: number;
|
|
color?: string;
|
|
alpha?: number;
|
|
rotation?: number;
|
|
radius?: number;
|
|
hue?: number;
|
|
pulseSpeed?: number;
|
|
pulseOffset?: number;
|
|
opacity?: number;
|
|
}
|
|
|
|
interface BgSnowflake {
|
|
x: number;
|
|
y: number;
|
|
size: number;
|
|
speed: number;
|
|
wobble?: number;
|
|
wobbleSpeed?: number;
|
|
rotation: number;
|
|
rotationSpeed: number;
|
|
alpha?: number;
|
|
color: string;
|
|
type?: number;
|
|
opacity?: number;
|
|
drift?: number;
|
|
driftSpeed?: number;
|
|
driftOffset?: number;
|
|
flakeType?: string;
|
|
}
|
|
|
|
interface FireworkRocket {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
palette: string[];
|
|
explosionType: string;
|
|
trail: Array<{ x: number; y: number; alpha?: number; size?: number }>;
|
|
trailTimer: number;
|
|
age: number;
|
|
exploded: boolean;
|
|
size: number;
|
|
}
|
|
|
|
interface FireworkParticle {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
color: string;
|
|
alpha: number;
|
|
size: number;
|
|
decay: number;
|
|
trail: Array<{ x: number; y: number; alpha?: number }>;
|
|
hasTrail: boolean;
|
|
gravity: number;
|
|
crackle?: boolean;
|
|
crackleTime?: number;
|
|
}
|
|
|
|
interface FireworkSpark {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
alpha: number;
|
|
size: number;
|
|
decay: number;
|
|
twinkle: number;
|
|
}
|
|
|
|
interface MouseParticle {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
size: number;
|
|
alpha: number;
|
|
decay: number;
|
|
color: string;
|
|
rotation: number;
|
|
rotationSpeed: number;
|
|
trail: Array<{ x: number; y: number; alpha?: number }> | null;
|
|
age: number;
|
|
effect: string;
|
|
// Effect-specific properties (all optional)
|
|
gravity?: number;
|
|
friction?: number;
|
|
shape?: string;
|
|
glow?: number;
|
|
hasTrail?: boolean;
|
|
trailLength?: number;
|
|
twinkle?: number;
|
|
drift?: number;
|
|
wave?: number;
|
|
flutter?: number;
|
|
sway?: number;
|
|
shimmer?: number;
|
|
spin?: boolean;
|
|
orbit?: boolean;
|
|
spiral?: boolean;
|
|
spiralAngle?: number | null;
|
|
spiralRadius?: number;
|
|
originX?: number;
|
|
originY?: number;
|
|
orbitAngle?: number | null;
|
|
orbitRadius?: number;
|
|
pulseGlow?: boolean;
|
|
orbCenter?: { x: number; y: number };
|
|
orbAngle?: number;
|
|
orbPhase?: number;
|
|
targetX?: number;
|
|
targetY?: number;
|
|
}
|
|
|
|
interface FinaleConfetti {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
rotation: number;
|
|
rotationSpeed: number;
|
|
alpha: number;
|
|
decay: number;
|
|
size: number;
|
|
color: string;
|
|
screenHeight?: number;
|
|
}
|
|
|
|
interface FinaleStar {
|
|
x: number;
|
|
y: number;
|
|
vy: number;
|
|
alpha: number;
|
|
size: number;
|
|
color: string;
|
|
trail: Array<{ x: number; y: number }>;
|
|
screenHeight?: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// State Variables
|
|
// ============================================================================
|
|
|
|
// Track if extension has been set up to prevent double registration
|
|
let extensionSetupComplete = false;
|
|
|
|
// Flag to prevent onChange callbacks during initial setup
|
|
let isInitialSetup = true;
|
|
|
|
// Page Visibility API - shared across modules
|
|
let isPageVisible = true;
|
|
document.addEventListener('visibilitychange', () => {
|
|
isPageVisible = document.visibilityState === 'visible';
|
|
});
|
|
|
|
// Workflow execution state - pauses animations during generation
|
|
let isExecuting = false;
|
|
let executionEndTime = 0; // Track when execution ended to add cooldown
|
|
|
|
// Store original method for cleanup
|
|
let originalDrawBackCanvas: ((...args: unknown[]) => void) | null = null;
|
|
|
|
// Star entities for the background effect
|
|
let starEntities: StarEntity[] = [];
|
|
let shootingStars: ShootingStar[] = [];
|
|
let nebulaEntities: NebulaEntity[] = [];
|
|
let starInitialized = false;
|
|
|
|
// Background snowflake entities (canvas-based, drawn behind nodes)
|
|
let bgSnowflakeEntities: BgSnowflake[] = [];
|
|
|
|
let bgSnowflakesInitialized = false;
|
|
let lastBgColorScheme: string | null = null;
|
|
let lastBgSnowflakeColorScheme: string | null = null;
|
|
let customBgImage: HTMLImageElement | null = null;
|
|
let customBgImageSrc: string | null = null;
|
|
let customSnowImage: HTMLImageElement | null = null;
|
|
let customSnowImageSrc: string | null = null;
|
|
|
|
|
|
// Firework entities
|
|
let fireworkRockets: FireworkRocket[] = [];
|
|
let fireworkParticles: FireworkParticle[] = [];
|
|
let fireworkSparks: FireworkSpark[] = []; // Secondary small particles
|
|
let lastFireworkTime = 0;
|
|
let animationLoopId: number | null = null;
|
|
|
|
// Color palettes for different firework types
|
|
const FIREWORK_PALETTES = [
|
|
['#ff6b6b', '#ff8787', '#ffa8a8'], // Red gradient
|
|
['#ffd93d', '#ffe066', '#fff3bf'], // Gold gradient
|
|
['#6bcb77', '#8ce99a', '#b2f2bb'], // Green gradient
|
|
['#4d96ff', '#74c0fc', '#a5d8ff'], // Blue gradient
|
|
['#ff85c1', '#f783ac', '#faa2c1'], // Pink gradient
|
|
['#a855f7', '#c084fc', '#d8b4fe'], // Purple gradient
|
|
['#00d4ff', '#22d3ee', '#67e8f9'], // Cyan gradient
|
|
['#ffffff', '#f8f9fa', '#e9ecef'], // White/silver
|
|
['#ffd700', '#ffec99', '#fff9db'], // Bright gold
|
|
];
|
|
|
|
// Explosion types
|
|
const EXPLOSION_TYPES = ['chrysanthemum', 'willow', 'palm', 'ring', 'crackle', 'peony'];
|
|
|
|
// Create a new firework rocket with enhanced properties
|
|
function createFireworkRocket(width: number, height: number, _isFinale?: boolean): FireworkRocket {
|
|
const palette = FIREWORK_PALETTES[Math.floor(Math.random() * FIREWORK_PALETTES.length)];
|
|
const explosionType = EXPLOSION_TYPES[Math.floor(Math.random() * EXPLOSION_TYPES.length)];
|
|
return {
|
|
x: Math.random() * width * 0.8 + width * 0.1,
|
|
y: height - 30,
|
|
vx: (Math.random() - 0.5) * 3,
|
|
vy: -12 - Math.random() * 6,
|
|
palette: palette,
|
|
explosionType: explosionType,
|
|
trail: [],
|
|
trailTimer: 0,
|
|
age: 0,
|
|
exploded: false,
|
|
size: 2 + Math.random()
|
|
};
|
|
}
|
|
|
|
// Create professional explosion particles based on type
|
|
function createExplosionParticles(x: number, y: number, palette: string[], explosionType: string): FireworkParticle[] {
|
|
const particles: FireworkParticle[] = [];
|
|
const primaryColor = palette[0];
|
|
const secondaryColor = palette[1];
|
|
const tertiaryColor = palette[2];
|
|
|
|
switch (explosionType) {
|
|
case 'chrysanthemum': {
|
|
// Dense spherical burst with long trails
|
|
const count = 80 + Math.floor(Math.random() * 40);
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.2;
|
|
const speed = 3 + Math.random() * 3;
|
|
const colorChoice = Math.random();
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
color: colorChoice < 0.5 ? primaryColor : (colorChoice < 0.8 ? secondaryColor : tertiaryColor),
|
|
alpha: 1,
|
|
size: 2 + Math.random() * 1.5,
|
|
decay: 0.008 + Math.random() * 0.004,
|
|
trail: [],
|
|
hasTrail: true,
|
|
gravity: 0.03
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'willow': {
|
|
// Drooping trails like a willow tree
|
|
const count = 60 + Math.floor(Math.random() * 30);
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.15;
|
|
const speed = 2 + Math.random() * 2;
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed - 1,
|
|
color: palette[Math.floor(Math.random() * palette.length)],
|
|
alpha: 1,
|
|
size: 1.5 + Math.random(),
|
|
decay: 0.005 + Math.random() * 0.003,
|
|
trail: [],
|
|
hasTrail: true,
|
|
gravity: 0.08 // Heavy gravity for drooping effect
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'palm': {
|
|
// Thick center burst spreading outward
|
|
const count = 50 + Math.floor(Math.random() * 20);
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count;
|
|
const speed = 4 + Math.random() * 2;
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed - 2,
|
|
color: primaryColor,
|
|
alpha: 1,
|
|
size: 3 + Math.random() * 2,
|
|
decay: 0.012 + Math.random() * 0.005,
|
|
trail: [],
|
|
hasTrail: true,
|
|
gravity: 0.04
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'ring': {
|
|
// Expanding ring shape
|
|
const count = 40;
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count;
|
|
const speed = 4;
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
color: palette[i % palette.length],
|
|
alpha: 1,
|
|
size: 2.5,
|
|
decay: 0.015,
|
|
trail: [],
|
|
hasTrail: false,
|
|
gravity: 0.02
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'crackle': {
|
|
// Initial burst then secondary mini explosions
|
|
const count = 30;
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.3;
|
|
const speed = 2 + Math.random() * 2;
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
color: primaryColor,
|
|
alpha: 1,
|
|
size: 2 + Math.random(),
|
|
decay: 0.02,
|
|
trail: [],
|
|
hasTrail: true,
|
|
gravity: 0.05,
|
|
crackle: true,
|
|
crackleTime: 0.3 + Math.random() * 0.3
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case 'peony':
|
|
default: {
|
|
// Classic spherical burst
|
|
const count = 70 + Math.floor(Math.random() * 30);
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.25;
|
|
const speed = 2.5 + Math.random() * 2.5;
|
|
particles.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
color: palette[Math.floor(Math.random() * palette.length)],
|
|
alpha: 1,
|
|
size: 2 + Math.random() * 1.5,
|
|
decay: 0.01 + Math.random() * 0.005,
|
|
trail: [],
|
|
hasTrail: Math.random() > 0.3,
|
|
gravity: 0.04
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add glitter/sparks for all types
|
|
const sparkCount = 20 + Math.floor(Math.random() * 15);
|
|
for (let i = 0; i < sparkCount; i++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = 1 + Math.random() * 5;
|
|
fireworkSparks.push({
|
|
x, y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
alpha: 1,
|
|
size: 0.5 + Math.random() * 0.5,
|
|
decay: 0.03 + Math.random() * 0.02,
|
|
twinkle: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
|
|
return particles;
|
|
}
|
|
|
|
// Function to update all background snowflake colors when settings change
|
|
function updateBgSnowflakeColors() {
|
|
for (const flake of bgSnowflakeEntities) {
|
|
flake.color = getBgSnowflakeColor();
|
|
}
|
|
}
|
|
|
|
// New Year Countdown Timer
|
|
let countdownElement: HTMLDivElement | null = null;
|
|
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function createCountdownElement() {
|
|
if (countdownElement) return countdownElement;
|
|
|
|
const now = new Date();
|
|
let targetYear = now.getFullYear();
|
|
// If we're past Jan 1, target next year
|
|
if (now.getMonth() > 0 || (now.getMonth() === 0 && now.getDate() > 1)) {
|
|
targetYear++;
|
|
}
|
|
|
|
const container = document.createElement('div');
|
|
container.id = 'christmas-theme-countdown';
|
|
container.innerHTML = `
|
|
<style>
|
|
#christmas-theme-countdown {
|
|
position: fixed;
|
|
bottom: 4px;
|
|
left: 120px;
|
|
background: #121212;
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 6px;
|
|
padding: 7px 12px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
color: #e0e0e0;
|
|
z-index: 50;
|
|
pointer-events: none;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
#christmas-theme-countdown .countdown-title {
|
|
font-size: 10px;
|
|
color: #b0b0b0;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
width: 100%;
|
|
}
|
|
#christmas-theme-countdown .countdown-title svg {
|
|
width: 12px;
|
|
height: 12px;
|
|
fill: none;
|
|
stroke: #ffd700;
|
|
stroke-width: 2;
|
|
stroke-linecap: round;
|
|
vertical-align: middle;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
top: -1px;
|
|
}
|
|
#christmas-theme-countdown .countdown-grid {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 2px;
|
|
margin-left: 5px;
|
|
}
|
|
#christmas-theme-countdown .countdown-segment {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-width: 22px;
|
|
}
|
|
#christmas-theme-countdown .countdown-segment span:first-child {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #ffffff;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1.1;
|
|
}
|
|
#christmas-theme-countdown .countdown-segment .label {
|
|
font-size: 8px;
|
|
color: #aaa;
|
|
margin-top: 1px;
|
|
}
|
|
#christmas-theme-countdown .countdown-sep {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #666;
|
|
line-height: 1.1;
|
|
}
|
|
#christmas-theme-countdown.celebration {
|
|
animation: celebrate 0.3s ease-in-out infinite;
|
|
border-color: #ffd700;
|
|
box-shadow: 0 0 15px rgba(255, 215, 0, 0.4);
|
|
}
|
|
@keyframes celebrate {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.1); }
|
|
}
|
|
</style>
|
|
<div class="countdown-title">
|
|
<svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.64 5.64l2.83 2.83M15.54 15.54l2.83 2.83M5.64 18.36l2.83-2.83M15.54 8.46l2.83-2.83"/><circle cx="12" cy="12" r="2"/></svg>
|
|
New Year ${targetYear}
|
|
<svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.64 5.64l2.83 2.83M15.54 15.54l2.83 2.83M5.64 18.36l2.83-2.83M15.54 8.46l2.83-2.83"/><circle cx="12" cy="12" r="2"/></svg>
|
|
</div>
|
|
<div class="countdown-grid">
|
|
<div class="countdown-segment"><span id="countdown-days">00</span><span class="label">Days</span></div>
|
|
<div class="countdown-sep">:</div>
|
|
<div class="countdown-segment"><span id="countdown-hours">00</span><span class="label">Hrs</span></div>
|
|
<div class="countdown-sep">:</div>
|
|
<div class="countdown-segment"><span id="countdown-mins">00</span><span class="label">Min</span></div>
|
|
<div class="countdown-sep">:</div>
|
|
<div class="countdown-segment"><span id="countdown-secs">00</span><span class="label">Sec</span></div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(container);
|
|
countdownElement = container;
|
|
return container;
|
|
}
|
|
|
|
// Finale state
|
|
let finaleActive = false;
|
|
let finaleStartTime = 0;
|
|
let finaleGracePeriod = false; // Keeps rendering particles after finale ends
|
|
const FINALE_DURATION = 22000; // 22 seconds of fireworks (extended for smooth wind-down)
|
|
let finaleConfetti: FinaleConfetti[] = [];
|
|
let finaleRings: unknown[] = [];
|
|
let finaleStars: FinaleStar[] = [];
|
|
|
|
function triggerFinale() {
|
|
if (finaleActive) return;
|
|
finaleActive = true;
|
|
finaleStartTime = performance.now();
|
|
console.log("🎆 FINALE TRIGGERED!");
|
|
|
|
// 1. Screen Flash
|
|
createScreenFlash();
|
|
|
|
// 2. "2026" Text Overlay
|
|
createYearText();
|
|
|
|
// 3. Golden Ring Expansion (multiple waves)
|
|
for (let i = 0; i < 3; i++) {
|
|
setTimeout(() => createGoldenRing(), i * 500);
|
|
}
|
|
|
|
// 4. Confetti Shower
|
|
createConfettiShower();
|
|
|
|
// 5. Cascade Fireworks (rapid fire)
|
|
triggerCascadeFireworks();
|
|
}
|
|
|
|
function createScreenFlash() {
|
|
const flash = document.createElement('div');
|
|
flash.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: radial-gradient(ellipse at center, rgba(255,255,255,0.9), rgba(255,215,0,0.6));
|
|
pointer-events: none;
|
|
z-index: 9999;
|
|
animation: finaleFlash 0.8s ease-out forwards;
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes finaleFlash {
|
|
0% { opacity: 1; }
|
|
100% { opacity: 0; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
document.body.appendChild(flash);
|
|
|
|
setTimeout(() => {
|
|
flash.remove();
|
|
style.remove();
|
|
}, 1000);
|
|
}
|
|
|
|
function createYearText() {
|
|
const now = new Date();
|
|
const year = now.getFullYear() + (now.getMonth() === 0 && now.getDate() === 1 ? 0 : 1);
|
|
|
|
const text = document.createElement('div');
|
|
text.innerHTML = `
|
|
<div style="font-size: 120px; font-weight: 900; text-shadow: 0 0 30px #ffd700, 0 0 60px #ff8c00;">${year}</div>
|
|
<div style="font-size: 36px; font-weight: 600; margin-top: 10px;">Happy New Year!</div>
|
|
`;
|
|
text.style.cssText = `
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: #ffd700;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
z-index: 9998;
|
|
animation: yearTextAnim 5s ease-out forwards;
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes yearTextAnim {
|
|
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.5); }
|
|
20% { opacity: 1; transform: translate(-50%, -50%) scale(1.1); }
|
|
30% { transform: translate(-50%, -50%) scale(1); }
|
|
80% { opacity: 1; }
|
|
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.2); }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
document.body.appendChild(text);
|
|
|
|
setTimeout(() => {
|
|
text.remove();
|
|
style.remove();
|
|
}, 5500);
|
|
}
|
|
|
|
function createGoldenRing() {
|
|
const ring = document.createElement('div');
|
|
ring.style.cssText = `
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
width: 10px;
|
|
height: 10px;
|
|
border: 4px solid #ffd700;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
pointer-events: none;
|
|
z-index: 9997;
|
|
box-shadow: 0 0 20px #ffd700, 0 0 40px #ff8c00;
|
|
animation: ringExpand 2s ease-out forwards;
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes ringExpand {
|
|
0% { width: 10px; height: 10px; opacity: 1; border-width: 4px; }
|
|
100% { width: 200vmax; height: 200vmax; opacity: 0; border-width: 2px; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
document.body.appendChild(ring);
|
|
|
|
setTimeout(() => {
|
|
ring.remove();
|
|
style.remove();
|
|
}, 2500);
|
|
}
|
|
|
|
function createConfettiShower() {
|
|
const colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#9d4edd', '#ff85a1', '#ffd700', '#00ffff'];
|
|
|
|
// Create confetti container
|
|
const container = document.createElement('div');
|
|
container.id = 'finale-confetti-container';
|
|
container.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
pointer-events: none;
|
|
z-index: 9996;
|
|
overflow: hidden;
|
|
`;
|
|
document.body.appendChild(container);
|
|
|
|
// Add confetti animation style
|
|
const style = document.createElement('style');
|
|
style.id = 'confetti-style';
|
|
style.textContent = `
|
|
@keyframes confettiFall {
|
|
0% { transform: translateY(-20px) rotate(0deg); opacity: 1; }
|
|
85% { opacity: 0.8; }
|
|
100% { transform: translateY(120vh) rotate(720deg); opacity: 0; }
|
|
}
|
|
.finale-confetti {
|
|
position: absolute;
|
|
animation: confettiFall linear forwards;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Create 150 confetti pieces
|
|
for (let i = 0; i < 150; i++) {
|
|
setTimeout(() => {
|
|
const confetti = document.createElement('div');
|
|
confetti.className = 'finale-confetti';
|
|
const size = 8 + Math.random() * 12;
|
|
const x = Math.random() * 100;
|
|
const duration = 3 + Math.random() * 4;
|
|
const delay = Math.random() * 0.5;
|
|
|
|
confetti.style.cssText = `
|
|
left: ${x}%;
|
|
top: -20px;
|
|
width: ${size}px;
|
|
height: ${size * 0.4}px;
|
|
background: ${colors[Math.floor(Math.random() * colors.length)]};
|
|
animation-duration: ${duration}s;
|
|
animation-delay: ${delay}s;
|
|
border-radius: 2px;
|
|
`;
|
|
container.appendChild(confetti);
|
|
}, Math.random() * 2000);
|
|
}
|
|
|
|
// Cleanup after 10 seconds
|
|
setTimeout(() => {
|
|
container.remove();
|
|
style.remove();
|
|
}, 10000);
|
|
}
|
|
|
|
function createStarRain() {
|
|
// Create star rain container
|
|
const container = document.createElement('div');
|
|
container.id = 'finale-star-container';
|
|
container.style.cssText = `
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
pointer-events: none;
|
|
z-index: 9995;
|
|
overflow: hidden;
|
|
`;
|
|
document.body.appendChild(container);
|
|
|
|
// Add star animation style
|
|
const style = document.createElement('style');
|
|
style.id = 'star-rain-style';
|
|
style.textContent = `
|
|
@keyframes starFall {
|
|
0% { transform: translateY(-20px); opacity: 1; }
|
|
80% { opacity: 1; }
|
|
100% { transform: translateY(100vh); opacity: 0; }
|
|
}
|
|
.finale-star {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
animation: starFall linear forwards;
|
|
box-shadow: 0 0 10px currentColor, 0 0 20px currentColor;
|
|
}
|
|
.finale-star::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, 0);
|
|
width: 2px;
|
|
height: 40px;
|
|
background: linear-gradient(to bottom, currentColor, transparent);
|
|
border-radius: 2px;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Create 40 falling stars
|
|
for (let i = 0; i < 40; i++) {
|
|
setTimeout(() => {
|
|
const star = document.createElement('div');
|
|
star.className = 'finale-star';
|
|
const size = 4 + Math.random() * 6;
|
|
const x = Math.random() * 100;
|
|
const duration = 1 + Math.random() * 1.5;
|
|
const color = Math.random() > 0.5 ? '#ffd700' : '#ffffff';
|
|
|
|
star.style.cssText = `
|
|
left: ${x}%;
|
|
top: -40px;
|
|
width: ${size}px;
|
|
height: ${size}px;
|
|
background: ${color};
|
|
color: ${color};
|
|
animation-duration: ${duration}s;
|
|
`;
|
|
container.appendChild(star);
|
|
}, Math.random() * 2000);
|
|
}
|
|
|
|
// Cleanup after 6 seconds
|
|
setTimeout(() => {
|
|
container.remove();
|
|
style.remove();
|
|
}, 6000);
|
|
}
|
|
|
|
function triggerCascadeFireworks() {
|
|
const canvas = document.getElementById('christmas-background') as HTMLCanvasElement | null;
|
|
if (!canvas) return;
|
|
|
|
// Phase 1: CLIMAX (0-4s) - Rapid-fire intense bursts
|
|
for (let i = 0; i < 25; i++) {
|
|
setTimeout(() => {
|
|
const x = canvas.width * 0.1 + Math.random() * canvas.width * 0.8;
|
|
fireworkRockets.push(createFireworkRocket(x, canvas.height, true));
|
|
}, i * 160); // Fast pacing
|
|
}
|
|
|
|
// Phase 2: SUSTAINED (4-10s) - Moderate, steady pacing
|
|
for (let i = 0; i < 15; i++) {
|
|
setTimeout(() => {
|
|
const x = canvas.width * 0.15 + Math.random() * canvas.width * 0.7;
|
|
fireworkRockets.push(createFireworkRocket(x, canvas.height, true));
|
|
}, 4000 + i * 400); // Medium pacing
|
|
}
|
|
|
|
// Phase 3: WIND-DOWN (10-18s) - Spaced-out bursts that gradually fade
|
|
for (let i = 0; i < 10; i++) {
|
|
setTimeout(() => {
|
|
const x = canvas.width * 0.2 + Math.random() * canvas.width * 0.6;
|
|
fireworkRockets.push(createFireworkRocket(x, canvas.height, true));
|
|
}, 10000 + i * 800); // Slow pacing, longer gaps
|
|
}
|
|
|
|
// Phase 4: FINAL TRAILING (18-22s) - Very spaced single bursts
|
|
for (let i = 0; i < 4; i++) {
|
|
setTimeout(() => {
|
|
const x = canvas.width * 0.25 + Math.random() * canvas.width * 0.5;
|
|
fireworkRockets.push(createFireworkRocket(x, canvas.height, true));
|
|
}, 18000 + i * 1000); // One per second
|
|
}
|
|
}
|
|
|
|
function updateFinaleParticles(ctx, canvas) {
|
|
// Update confetti
|
|
for (let i = finaleConfetti.length - 1; i >= 0; i--) {
|
|
const c = finaleConfetti[i];
|
|
c.x += c.vx;
|
|
c.y += c.vy;
|
|
c.vy += 0.05; // gravity
|
|
c.vx *= 0.99;
|
|
c.rotation += c.rotationSpeed;
|
|
c.alpha -= c.decay;
|
|
|
|
const maxHeight = c.screenHeight || window.innerHeight;
|
|
if (c.alpha <= 0 || c.y > maxHeight + 50) {
|
|
finaleConfetti.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Update falling stars
|
|
for (let i = finaleStars.length - 1; i >= 0; i--) {
|
|
const s = finaleStars[i];
|
|
s.trail.push({ x: s.x, y: s.y });
|
|
if (s.trail.length > 15) s.trail.shift();
|
|
|
|
s.y += s.vy;
|
|
s.alpha -= 0.01;
|
|
|
|
const maxHeight = s.screenHeight || window.innerHeight;
|
|
if (s.alpha <= 0 || s.y > maxHeight + 50) {
|
|
finaleStars.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawFinaleParticles(ctx) {
|
|
// Draw confetti
|
|
for (const c of finaleConfetti) {
|
|
ctx.save();
|
|
ctx.translate(c.x, c.y);
|
|
ctx.rotate(c.rotation);
|
|
ctx.globalAlpha = c.alpha;
|
|
ctx.fillStyle = c.color;
|
|
ctx.fillRect(-c.size / 2, -c.size / 4, c.size, c.size / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Draw falling stars with trails
|
|
for (const s of finaleStars) {
|
|
// Trail
|
|
for (let t = 0; t < s.trail.length; t++) {
|
|
const tp = s.trail[t];
|
|
ctx.globalAlpha = (t / s.trail.length) * s.alpha * 0.5;
|
|
ctx.fillStyle = s.color;
|
|
ctx.beginPath();
|
|
ctx.arc(tp.x, tp.y, s.size * 0.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Star
|
|
ctx.globalAlpha = s.alpha;
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowColor = s.color;
|
|
ctx.fillStyle = s.color;
|
|
ctx.beginPath();
|
|
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
function updateCountdown() {
|
|
const daysEl = document.getElementById('countdown-days');
|
|
const hoursEl = document.getElementById('countdown-hours');
|
|
const minsEl = document.getElementById('countdown-mins');
|
|
const secsEl = document.getElementById('countdown-secs');
|
|
if (!daysEl || !hoursEl || !minsEl || !secsEl) return;
|
|
|
|
const now = new Date();
|
|
let targetYear = now.getFullYear();
|
|
// If we're past Jan 1, target next year
|
|
if (now.getMonth() > 0 || (now.getMonth() === 0 && now.getDate() > 1)) {
|
|
targetYear++;
|
|
}
|
|
const newYear = new Date(targetYear, 0, 1, 0, 0, 0);
|
|
const diff = newYear.getTime() - now.getTime();
|
|
|
|
if (diff <= 0) {
|
|
daysEl.textContent = '00';
|
|
hoursEl.textContent = '00';
|
|
minsEl.textContent = '00';
|
|
secsEl.textContent = '00';
|
|
if (countdownElement) {
|
|
countdownElement.classList.add('celebration');
|
|
}
|
|
// Show finale button instead of auto-triggering (easter egg)
|
|
showFinaleButton();
|
|
return;
|
|
}
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
|
|
daysEl.textContent = days.toString().padStart(2, '0');
|
|
hoursEl.textContent = hours.toString().padStart(2, '0');
|
|
minsEl.textContent = minutes.toString().padStart(2, '0');
|
|
secsEl.textContent = seconds.toString().padStart(2, '0');
|
|
|
|
// Hide finale button if time is not zero AND setting is off
|
|
if (!getSetting("ChristmasTheme.Background.ShowFinaleButton")) {
|
|
hideFinaleButton();
|
|
}
|
|
}
|
|
|
|
function toggleCountdownDisplay(enabled) {
|
|
if (enabled) {
|
|
createCountdownElement();
|
|
countdownElement.style.display = 'block';
|
|
updateCountdown();
|
|
if (!countdownInterval) {
|
|
countdownInterval = setInterval(updateCountdown, 1000);
|
|
}
|
|
// Show finale button if setting enabled
|
|
if (getSetting("ChristmasTheme.Background.ShowFinaleButton")) {
|
|
showFinaleButton();
|
|
}
|
|
} else {
|
|
if (countdownElement) {
|
|
countdownElement.style.display = 'none';
|
|
}
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
hideFinaleButton();
|
|
}
|
|
}
|
|
|
|
// Finale button (appears at midnight as easter egg)
|
|
let finaleButtonElement: HTMLButtonElement | null = null;
|
|
let finaleButtonShown = false;
|
|
|
|
function showFinaleButton() {
|
|
if (finaleButtonShown || finaleActive) return;
|
|
finaleButtonShown = true;
|
|
|
|
if (finaleButtonElement) {
|
|
finaleButtonElement.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
const btn = document.createElement('button');
|
|
btn.id = 'finale-trigger-btn';
|
|
btn.innerHTML = `Happy New Year!`;
|
|
btn.style.cssText = `
|
|
position: fixed;
|
|
bottom: 61px;
|
|
right: 277px;
|
|
padding: 6px 12px;
|
|
background: rgba(35, 35, 35, 0.95);
|
|
border: 1px solid #ffd700;
|
|
border-radius: 6px;
|
|
color: #ffd700;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
z-index: 10000;
|
|
font-size: 11px;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
transition: all 0.2s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
`;
|
|
btn.onmouseenter = () => {
|
|
btn.style.background = 'rgba(50, 50, 50, 0.95)';
|
|
btn.style.boxShadow = '0 0 15px rgba(255, 215, 0, 0.4)';
|
|
};
|
|
btn.onmouseleave = () => {
|
|
btn.style.background = 'rgba(35, 35, 35, 0.95)';
|
|
btn.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
|
|
};
|
|
btn.onclick = () => {
|
|
finaleActive = false; // Reset so it triggers fresh
|
|
triggerFinale();
|
|
};
|
|
document.body.appendChild(btn);
|
|
finaleButtonElement = btn;
|
|
}
|
|
|
|
function hideFinaleButton() {
|
|
finaleButtonShown = false;
|
|
if (finaleButtonElement) {
|
|
finaleButtonElement.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
|
|
// ✨ Mouse Effects System (Modular)
|
|
let mouseParticles: MouseParticle[] = [];
|
|
let mouseX = 0;
|
|
let mouseY = 0;
|
|
let lastMouseX = 0;
|
|
let lastMouseY = 0;
|
|
let mouseEffectInitialized = false;
|
|
|
|
// Effect configurations (DATA) - each effect defines its behavior
|
|
const MOUSE_EFFECTS = {
|
|
none: null,
|
|
sparkler: {
|
|
colors: ['#ffd700', '#ffec99', '#fff9db', '#ffffff'],
|
|
gravity: 0.08, decay: 0.015, size: [1, 3], spread: 4, friction: 0.98,
|
|
shape: 'circle', glow: 10, hasTrail: true, twinkle: true,
|
|
spawnMode: 'burst' // explodes outward from cursor
|
|
},
|
|
snowflake: {
|
|
colors: ['#ffffff', '#e0f7ff', '#b8e6ff', '#d4f1f9'],
|
|
gravity: 0.015, decay: 0.006, size: [3, 6], spread: 0.5, friction: 0.99,
|
|
shape: 'snowflake', glow: 8, hasTrail: false, drift: true, rotation: true,
|
|
spawnMode: 'drop' // drops gently from cursor position
|
|
},
|
|
confetti: {
|
|
colors: ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#9d4edd', '#ff85a1'],
|
|
gravity: 0.05, decay: 0.008, size: [3, 5], spread: 6, friction: 0.97,
|
|
shape: 'rectangle', glow: 0, spin: true,
|
|
spawnMode: 'pop' // pops upward then falls
|
|
},
|
|
stardust: {
|
|
colors: ['#fffacd', '#fff8dc', '#fffaf0', '#ffffff'],
|
|
gravity: 0.005, decay: 0.01, size: [1, 2], spread: 2, friction: 0.995,
|
|
shape: 'star', glow: 12, twinkle: true,
|
|
spawnMode: 'float' // floats gently in place
|
|
},
|
|
comet: {
|
|
colors: ['#87ceeb', '#add8e6', '#ffffff'],
|
|
gravity: 0.0, decay: 0.025, size: [2, 4], spread: 0.2, friction: 0.92,
|
|
shape: 'circle', glow: 15, hasTrail: true, trailLength: 15,
|
|
spawnMode: 'follow' // tight follow, long trail
|
|
},
|
|
aurora: {
|
|
colors: ['#00ff88', '#00ffcc', '#00ccff', '#8844ff'],
|
|
gravity: -0.02, decay: 0.006, size: [2, 4], spread: 1, friction: 0.99,
|
|
shape: 'circle', glow: 20, wave: true,
|
|
spawnMode: 'rise' // floats upward with wave
|
|
},
|
|
ribbon: {
|
|
colors: ['#ff69b4', '#ff1493', '#db7093', '#ffb6c1'],
|
|
gravity: 0.0, decay: 0.02, size: [2, 3], spread: 0.1, friction: 0.85,
|
|
shape: 'circle', glow: 5, hasTrail: true, trailLength: 10,
|
|
spawnMode: 'follow' // smooth following ribbon
|
|
},
|
|
crystal: {
|
|
colors: ['#e0ffff', '#afeeee', '#b0e0e6', '#ffffff'],
|
|
gravity: 0.03, decay: 0.01, size: [3, 5], spread: 2, friction: 0.96,
|
|
shape: 'diamond', glow: 15, shimmer: true,
|
|
spawnMode: 'scatter' // scatters in random directions
|
|
},
|
|
petals: {
|
|
colors: ['#ffb7c5', '#ffc0cb', '#ff69b4', '#fff0f5'],
|
|
gravity: 0.02, decay: 0.008, size: [4, 6], spread: 3, friction: 0.98,
|
|
shape: 'petal', glow: 5, flutter: true, rotation: true,
|
|
spawnMode: 'flutter' // drifts with flutter motion
|
|
},
|
|
gifts: {
|
|
colors: ['#ff6b6b', '#4ade80', '#ffd700', '#60a5fa'],
|
|
gravity: 0.06, decay: 0.008, size: [5, 8], spread: 3, friction: 0.96,
|
|
shape: 'box', glow: 0, rotation: true,
|
|
spawnMode: 'toss' // tossed upward then tumbles
|
|
},
|
|
candy: {
|
|
colors: ['#ff0000', '#ffffff'],
|
|
gravity: 0.035, decay: 0.01, size: [3, 5], spread: 4, friction: 0.97,
|
|
shape: 'candy', glow: 5, spin: true,
|
|
spawnMode: 'bounce' // bouncy scatter
|
|
},
|
|
orb: {
|
|
colors: ['#9d4edd', '#7b2cbf', '#c77dff', '#e0aaff'],
|
|
gravity: 0.0, decay: 0.015, size: [3, 5], spread: 0.5, friction: 0.95,
|
|
shape: 'circle', glow: 25, orbit: true, pulseGlow: true,
|
|
spawnMode: 'orbit' // orbits around spawn point
|
|
},
|
|
magic: {
|
|
colors: ['#ffd700', '#ff69b4', '#00ffff', '#ff6b6b', '#9d4edd'],
|
|
gravity: 0.0, decay: 0.015, size: [1, 2], spread: 0.5, friction: 0.98,
|
|
shape: 'star', glow: 15, spiral: true, twinkle: true,
|
|
spawnMode: 'spiral' // spirals outward from cursor
|
|
},
|
|
nova: {
|
|
colors: ['#ffffff', '#fffacd', '#ffd700', '#ff8c00'],
|
|
gravity: 0.0, decay: 0.02, size: [2, 4], spread: 0, friction: 0.96,
|
|
shape: 'star', glow: 20, twinkle: true,
|
|
spawnMode: 'burst' // expanding starburst
|
|
},
|
|
bubbles: {
|
|
colors: ['#87ceeb', '#b0e0e6', '#e0ffff', '#ffffff'],
|
|
gravity: -0.03, decay: 0.008, size: [4, 8], spread: 2, friction: 0.99,
|
|
shape: 'bubble', glow: 8, shimmer: true,
|
|
spawnMode: 'rise' // floating upward
|
|
},
|
|
embers: {
|
|
colors: ['#ff4500', '#ff6347', '#ffa500', '#ffd700', '#ffec99'],
|
|
gravity: -0.02, decay: 0.012, size: [1, 3], spread: 2, friction: 0.98,
|
|
shape: 'circle', glow: 12, twinkle: true,
|
|
spawnMode: 'rise' // drifting upward like fire
|
|
},
|
|
lightning: {
|
|
colors: ['#00ffff', '#87ceeb', '#ffffff', '#e0ffff'],
|
|
gravity: 0.0, decay: 0.04, size: [1, 2], spread: 3, friction: 0.9,
|
|
shape: 'circle', glow: 20, hasTrail: true, trailLength: 6,
|
|
spawnMode: 'scatter' // quick electric sparks
|
|
},
|
|
leaves: {
|
|
colors: ['#8b4513', '#d2691e', '#cd853f', '#f4a460', '#daa520'],
|
|
gravity: 0.025, decay: 0.008, size: [4, 7], spread: 3, friction: 0.98,
|
|
shape: 'leaf', glow: 0, flutter: true, rotation: true,
|
|
spawnMode: 'flutter' // drifting down with spin
|
|
},
|
|
wishes: {
|
|
colors: ['#ffffff', '#fffacd', '#ffd700'],
|
|
gravity: 0.0, decay: 0.03, size: [1, 2], spread: 0.5, friction: 0.85,
|
|
shape: 'circle', glow: 15, hasTrail: true, trailLength: 20,
|
|
spawnMode: 'follow' // shooting star streaks
|
|
},
|
|
notes: {
|
|
colors: ['#ff69b4', '#9d4edd', '#4d96ff', '#ffd700'],
|
|
gravity: -0.015, decay: 0.01, size: [6, 10], spread: 3, friction: 0.98,
|
|
shape: 'note', glow: 5, sway: true,
|
|
spawnMode: 'rise' // floating musical notes
|
|
},
|
|
hearts: {
|
|
colors: ['#ff69b4', '#ff1493', '#ff6b6b', '#ff85a1'],
|
|
gravity: -0.01, decay: 0.01, size: [4, 7], spread: 2, friction: 0.98,
|
|
shape: 'heart', glow: 8, sway: true,
|
|
spawnMode: 'rise' // floating hearts
|
|
}
|
|
};
|
|
|
|
// Shape renderers (DRAWING) - how each shape is drawn
|
|
const PARTICLE_SHAPES = {
|
|
circle: (ctx, p) => {
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
},
|
|
star: (ctx, p) => {
|
|
const spikes = 5, outerR = p.size, innerR = p.size * 0.5;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < spikes * 2; i++) {
|
|
const r = i % 2 === 0 ? outerR : innerR;
|
|
const angle = (i * Math.PI / spikes) - Math.PI / 2 + (p.rotation || 0);
|
|
const x = p.x + Math.cos(angle) * r;
|
|
const y = p.y + Math.sin(angle) * r;
|
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
},
|
|
diamond: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, p.y - s);
|
|
ctx.lineTo(p.x + s * 0.6, p.y);
|
|
ctx.lineTo(p.x, p.y + s);
|
|
ctx.lineTo(p.x - s * 0.6, p.y);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
},
|
|
snowflake: (ctx, p) => {
|
|
const s = p.size, arms = 6;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = ctx.fillStyle;
|
|
for (let i = 0; i < arms; i++) {
|
|
ctx.rotate(Math.PI / 3);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -s);
|
|
ctx.moveTo(0, -s * 0.5);
|
|
ctx.lineTo(-s * 0.3, -s * 0.7);
|
|
ctx.moveTo(0, -s * 0.5);
|
|
ctx.lineTo(s * 0.3, -s * 0.7);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
},
|
|
rectangle: (ctx, p) => {
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
|
|
ctx.restore();
|
|
},
|
|
petal: (ctx, p) => {
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, p.size * 0.4, p.size, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
},
|
|
bell: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y - s * 0.3, s * 0.7, Math.PI, 0);
|
|
ctx.quadraticCurveTo(p.x + s * 0.8, p.y + s * 0.5, p.x, p.y + s * 0.6);
|
|
ctx.quadraticCurveTo(p.x - s * 0.8, p.y + s * 0.5, p.x - s * 0.7, p.y - s * 0.3);
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y + s * 0.7, s * 0.15, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
},
|
|
box: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
ctx.fillRect(-s / 2, -s / 2, s, s);
|
|
// Ribbon
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.globalAlpha *= 0.8;
|
|
ctx.fillRect(-s / 2, -s * 0.1, s, s * 0.2);
|
|
ctx.fillRect(-s * 0.1, -s / 2, s * 0.2, s);
|
|
ctx.restore();
|
|
},
|
|
candy: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
// Striped circle
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, s, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#ffffff';
|
|
for (let i = 0; i < 3; i++) {
|
|
ctx.fillRect(-s, -s + i * s * 0.7, s * 2, s * 0.3);
|
|
}
|
|
ctx.restore();
|
|
},
|
|
bubble: (ctx, p) => {
|
|
const s = p.size;
|
|
// Outer bubble
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, s, 0, Math.PI * 2);
|
|
ctx.globalAlpha *= 0.4;
|
|
ctx.fill();
|
|
// Highlight shine
|
|
ctx.globalAlpha *= 2;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.beginPath();
|
|
ctx.arc(p.x - s * 0.3, p.y - s * 0.3, s * 0.2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
},
|
|
leaf: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.rotate(p.rotation || 0);
|
|
// Leaf shape
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -s);
|
|
ctx.quadraticCurveTo(s * 0.8, -s * 0.3, 0, s);
|
|
ctx.quadraticCurveTo(-s * 0.8, -s * 0.3, 0, -s);
|
|
ctx.fill();
|
|
// Vein
|
|
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -s * 0.8);
|
|
ctx.lineTo(0, s * 0.8);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
},
|
|
note: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
// Note head (filled oval)
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, s * 0.5, s * 0.35, -0.3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
// Stem
|
|
ctx.fillRect(s * 0.4, -s * 1.2, s * 0.12, s * 1.2);
|
|
// Flag
|
|
ctx.beginPath();
|
|
ctx.moveTo(s * 0.52, -s * 1.2);
|
|
ctx.quadraticCurveTo(s * 1.2, -s * 0.8, s * 0.52, -s * 0.4);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
},
|
|
heart: (ctx, p) => {
|
|
const s = p.size;
|
|
ctx.save();
|
|
ctx.translate(p.x, p.y);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, s * 0.3);
|
|
// Left curve
|
|
ctx.bezierCurveTo(-s * 0.5, -s * 0.3, -s, s * 0.1, 0, s);
|
|
// Right curve
|
|
ctx.bezierCurveTo(s, s * 0.1, s * 0.5, -s * 0.3, 0, s * 0.3);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
};
|
|
|
|
function initMouseEffect() {
|
|
if (mouseEffectInitialized) return;
|
|
mouseEffectInitialized = true;
|
|
document.addEventListener('mousemove', (e) => {
|
|
mouseX = e.clientX;
|
|
mouseY = e.clientY;
|
|
});
|
|
console.log("✨ Mouse effects system initialized");
|
|
}
|
|
|
|
function createMouseParticle(x, y, vx, vy, effect) {
|
|
const config = MOUSE_EFFECTS[effect];
|
|
if (!config) return null;
|
|
|
|
const sizeRange = config.size;
|
|
const size = sizeRange[0] + Math.random() * (sizeRange[1] - sizeRange[0]);
|
|
|
|
// Spawn-mode-specific initial velocities
|
|
let pVx, pVy;
|
|
const spread = config.spread;
|
|
const angle = Math.random() * Math.PI * 2;
|
|
|
|
switch (config.spawnMode) {
|
|
case 'burst': // Explodes outward in all directions
|
|
pVx = Math.cos(angle) * (2 + Math.random() * 3);
|
|
pVy = Math.sin(angle) * (2 + Math.random() * 3);
|
|
break;
|
|
case 'drop': // Falls gently downward
|
|
pVx = (Math.random() - 0.5) * spread;
|
|
pVy = Math.random() * 0.5;
|
|
break;
|
|
case 'pop': // Pops upward then falls
|
|
pVx = (Math.random() - 0.5) * spread;
|
|
pVy = -2 - Math.random() * 3;
|
|
break;
|
|
case 'float': // Drifts slowly in place
|
|
pVx = (Math.random() - 0.5) * 0.5;
|
|
pVy = (Math.random() - 0.5) * 0.5;
|
|
break;
|
|
case 'follow': // Tight follow - inherits cursor velocity
|
|
pVx = vx * 0.3 + (Math.random() - 0.5) * spread;
|
|
pVy = vy * 0.3 + (Math.random() - 0.5) * spread;
|
|
break;
|
|
case 'rise': // Floats upward
|
|
pVx = (Math.random() - 0.5) * spread;
|
|
pVy = -0.5 - Math.random();
|
|
break;
|
|
case 'scatter': // Random directions
|
|
pVx = (Math.random() - 0.5) * 4;
|
|
pVy = (Math.random() - 0.5) * 4;
|
|
break;
|
|
case 'flutter': // Gentle with horizontal drift
|
|
pVx = (Math.random() - 0.5) * 2;
|
|
pVy = Math.random() * 0.5;
|
|
break;
|
|
case 'toss': // Thrown upward with arc
|
|
pVx = (Math.random() - 0.5) * spread + vx * 0.2;
|
|
pVy = -1.5 - Math.random() * 2;
|
|
break;
|
|
case 'bounce': // Bouncy with some upward
|
|
pVx = (Math.random() - 0.5) * spread;
|
|
pVy = -1 - Math.random();
|
|
break;
|
|
case 'orbit': // Circular motion around spawn
|
|
pVx = Math.cos(angle) * 2;
|
|
pVy = Math.sin(angle) * 2;
|
|
break;
|
|
case 'spiral': // Spiraling outward
|
|
pVx = Math.cos(angle) * 1.5;
|
|
pVy = Math.sin(angle) * 1.5;
|
|
break;
|
|
default:
|
|
pVx = vx + (Math.random() - 0.5) * spread;
|
|
pVy = vy + (Math.random() - 0.5) * spread;
|
|
}
|
|
|
|
return {
|
|
x, y,
|
|
vx: pVx,
|
|
vy: pVy,
|
|
alpha: 1,
|
|
size: size,
|
|
color: config.colors[Math.floor(Math.random() * config.colors.length)],
|
|
gravity: config.gravity + (Math.random() - 0.5) * 0.01,
|
|
decay: config.decay + Math.random() * 0.003,
|
|
friction: config.friction || 0.98,
|
|
shape: config.shape,
|
|
glow: config.glow,
|
|
twinkle: config.twinkle ? Math.random() * Math.PI * 2 : 0,
|
|
rotation: config.rotation ? Math.random() * Math.PI * 2 : 0,
|
|
rotationSpeed: config.spin ? (Math.random() - 0.5) * 0.3 : (config.rotation ? 0.02 : 0),
|
|
trail: config.hasTrail ? [] : null,
|
|
trailLength: config.trailLength || 5,
|
|
wave: config.wave ? Math.random() * Math.PI * 2 : 0,
|
|
orbitAngle: config.orbit ? angle : null,
|
|
orbitRadius: config.orbit ? 20 + Math.random() * 30 : 0,
|
|
originX: x,
|
|
originY: y,
|
|
spiralAngle: config.spiral ? angle : null,
|
|
spiralRadius: config.spiral ? 5 : 0,
|
|
drift: config.drift ? (Math.random() - 0.5) * 1.5 : 0,
|
|
flutter: config.flutter ? Math.random() * Math.PI * 2 : 0,
|
|
sway: config.sway ? Math.random() * Math.PI * 2 : 0,
|
|
shimmer: config.shimmer ? Math.random() * Math.PI * 2 : 0,
|
|
pulseGlow: config.pulseGlow || false,
|
|
age: 0,
|
|
effect: effect
|
|
};
|
|
}
|
|
|
|
function updateMouseParticles(deltaTime) {
|
|
const effectType = getSetting("ChristmasTheme.Background.MouseEffect");
|
|
|
|
if (!effectType || effectType === "none") {
|
|
mouseParticles = [];
|
|
return;
|
|
}
|
|
|
|
if (!mouseEffectInitialized) {
|
|
initMouseEffect();
|
|
}
|
|
|
|
const config = MOUSE_EFFECTS[effectType];
|
|
if (!config) return;
|
|
|
|
// Calculate mouse velocity
|
|
const dx = mouseX - lastMouseX;
|
|
const dy = mouseY - lastMouseY;
|
|
const speed = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Spawn particles when mouse moves
|
|
if (speed > 2) {
|
|
const particleCount = Math.min(Math.floor(speed / 3), 5);
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const t = i / particleCount;
|
|
const px = lastMouseX + dx * t;
|
|
const py = lastMouseY + dy * t;
|
|
const particle = createMouseParticle(px, py, dx * 0.1, dy * 0.1, effectType);
|
|
if (particle) mouseParticles.push(particle);
|
|
}
|
|
}
|
|
|
|
lastMouseX = mouseX;
|
|
lastMouseY = mouseY;
|
|
|
|
// Update particles
|
|
for (let i = mouseParticles.length - 1; i >= 0; i--) {
|
|
const p = mouseParticles[i];
|
|
|
|
// Store trail
|
|
if (p.trail && p.trail.length < p.trailLength) {
|
|
p.trail.push({ x: p.x, y: p.y, alpha: p.alpha });
|
|
}
|
|
|
|
// Physics
|
|
p.vy += p.gravity;
|
|
p.x += p.vx + (p.drift || 0) * Math.sin(p.flutter || 0);
|
|
p.y += p.vy;
|
|
p.vx *= p.friction;
|
|
p.vy *= p.friction;
|
|
p.alpha -= p.decay;
|
|
|
|
// Effect-specific updates
|
|
if (p.twinkle) p.twinkle += 0.2;
|
|
if (p.rotationSpeed) p.rotation += p.rotationSpeed;
|
|
if (p.wave) { p.wave += 0.1; p.x += Math.sin(p.wave) * 2; }
|
|
if (p.flutter) p.flutter += 0.15;
|
|
if (p.sway) { p.sway += 0.1; p.x += Math.sin(p.sway) * 1.5; }
|
|
if (p.shimmer) p.shimmer += 0.3;
|
|
|
|
// Spiral motion - expands outward while spinning
|
|
if (p.spiralAngle !== null) {
|
|
p.spiralAngle += 0.15;
|
|
p.spiralRadius += 0.5;
|
|
p.x = p.originX + Math.cos(p.spiralAngle) * p.spiralRadius;
|
|
p.y = p.originY + Math.sin(p.spiralAngle) * p.spiralRadius;
|
|
}
|
|
|
|
// Orbit motion - circles around origin
|
|
if (p.orbitAngle !== null) {
|
|
p.orbitAngle += 0.08;
|
|
p.x = p.originX + Math.cos(p.orbitAngle) * p.orbitRadius;
|
|
p.y = p.originY + Math.sin(p.orbitAngle) * p.orbitRadius;
|
|
}
|
|
|
|
if (p.alpha <= 0) {
|
|
mouseParticles.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// Performance limit
|
|
if (mouseParticles.length > 200) {
|
|
mouseParticles.splice(0, mouseParticles.length - 200);
|
|
}
|
|
}
|
|
|
|
function drawMouseParticles(ctx) {
|
|
const effectType = getSetting("ChristmasTheme.Background.MouseEffect");
|
|
if (!effectType || effectType === "none" || mouseParticles.length === 0) return;
|
|
|
|
ctx.save();
|
|
|
|
for (const p of mouseParticles) {
|
|
// Draw trail
|
|
if (p.trail && p.trail.length > 0) {
|
|
for (let t = 0; t < p.trail.length; t++) {
|
|
const tp = p.trail[t];
|
|
ctx.globalAlpha = (t / p.trail.length) * p.alpha * 0.4;
|
|
ctx.fillStyle = p.color;
|
|
ctx.beginPath();
|
|
ctx.arc(tp.x, tp.y, p.size * 0.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Calculate alpha with effects
|
|
let finalAlpha = p.alpha;
|
|
if (p.twinkle) finalAlpha *= (0.7 + Math.sin(p.twinkle) * 0.3);
|
|
if (p.shimmer) finalAlpha *= (0.8 + Math.sin(p.shimmer) * 0.2);
|
|
|
|
// Glow
|
|
ctx.shadowBlur = p.pulseGlow ? p.glow * (0.7 + Math.sin(p.twinkle || 0) * 0.3) : p.glow;
|
|
ctx.shadowColor = p.color;
|
|
ctx.globalAlpha = finalAlpha;
|
|
ctx.fillStyle = p.color;
|
|
|
|
// Draw shape
|
|
const shapeRenderer = PARTICLE_SHAPES[p.shape] || PARTICLE_SHAPES.circle;
|
|
shapeRenderer(ctx, p);
|
|
}
|
|
|
|
ctx.shadowBlur = 0;
|
|
ctx.restore();
|
|
}
|
|
|
|
// Gradient caching
|
|
let cachedGradient = null;
|
|
let cachedTheme = null;
|
|
let cachedWidth = 0;
|
|
let cachedHeight = 0;
|
|
|
|
// Performance tracking
|
|
let frameCount = 0;
|
|
let lastFpsCheck = performance.now();
|
|
let currentFps = 60;
|
|
let lowPerfMode = false;
|
|
|
|
// Animation timing
|
|
let lastAnimationTime = 0;
|
|
let animationTime = 0; // Cumulative time that freezes during execution
|
|
let lastShootingStarTime = 0;
|
|
|
|
/**
|
|
* Get a star color based on temperature (blue hot -> yellow cool)
|
|
*/
|
|
function getStarColor() {
|
|
const roll = Math.random();
|
|
if (roll < 0.15) return '#aaccff'; // Blue-white (hot)
|
|
if (roll < 0.50) return '#ffffff'; // White (common)
|
|
if (roll < 0.75) return '#fffef0'; // Warm white
|
|
if (roll < 0.90) return '#fff4e0'; // Yellow-white (sun-like)
|
|
return '#ffe4c0'; // Orange-ish (cooler)
|
|
}
|
|
|
|
/**
|
|
* Initialize star entities for background with depth layers
|
|
*/
|
|
function initStars(width, height) {
|
|
const area = width * height;
|
|
const density = 0.0003; // slightly reduced density
|
|
const count = Math.min(Math.floor(area * density), 800); // Cap at 800 stars
|
|
|
|
starEntities = [];
|
|
for (let i = 0; i < count; i++) {
|
|
// Create 3 layers: distant (small, dim), normal, bright (rare, glow)
|
|
const layerRoll = Math.random();
|
|
let layer, size, baseOpacity, twinkleSpeed, hasGlow, hasSpikes;
|
|
|
|
if (layerRoll < 0.6) {
|
|
// Distant stars (60%) - tiny, dim, very slow twinkle
|
|
layer = 'distant';
|
|
size = 0.3 + Math.random() * 0.5;
|
|
baseOpacity = 0.2 + Math.random() * 0.3;
|
|
twinkleSpeed = 0.08 + Math.random() * 0.15; // Slower for calm feel
|
|
hasGlow = false;
|
|
hasSpikes = false;
|
|
} else if (layerRoll < 0.92) {
|
|
// Normal stars (32%) - medium, gentle twinkle
|
|
layer = 'normal';
|
|
size = 0.5 + Math.random() * 0.8;
|
|
baseOpacity = 0.4 + Math.random() * 0.4;
|
|
twinkleSpeed = 0.15 + Math.random() * 0.3; // Slower
|
|
hasGlow = false;
|
|
hasSpikes = false;
|
|
} else {
|
|
// Bright stars (8%) - larger, with glow and possible spikes
|
|
layer = 'bright';
|
|
size = 0.8 + Math.random() * 0.7;
|
|
baseOpacity = 0.7 + Math.random() * 0.3;
|
|
twinkleSpeed = 0.2 + Math.random() * 0.4; // Slower
|
|
hasGlow = true;
|
|
hasSpikes = Math.random() < 0.4; // 40% of bright stars get spikes
|
|
}
|
|
|
|
starEntities.push({
|
|
x: Math.random() * width,
|
|
y: Math.random() * height,
|
|
size,
|
|
// Use much larger offset range + secondary offset to prevent sync
|
|
twinkleOffset: Math.random() * Math.PI * 20, // Larger range
|
|
twinkleOffset2: Math.random() * Math.PI * 15, // Secondary offset
|
|
twinkleSpeed,
|
|
// Add slight speed variation per star
|
|
twinkleSpeedMod: 0.85 + Math.random() * 0.3,
|
|
baseOpacity,
|
|
layer,
|
|
hasGlow,
|
|
hasSpikes,
|
|
color: getStarColor()
|
|
});
|
|
}
|
|
|
|
// Create nebula clouds (soft colored areas)
|
|
nebulaEntities = [];
|
|
const nebulaCount = Math.min(Math.floor(count * 0.01), 5); // Just a few nebulae
|
|
for (let i = 0; i < nebulaCount; i++) {
|
|
nebulaEntities.push({
|
|
x: Math.random() * width,
|
|
y: Math.random() * height * 0.6, // More in upper area
|
|
radius: 100 + Math.random() * 150, // Larger but more diffuse
|
|
hue: Math.random() * 60 - 30,
|
|
opacity: 0.015 + Math.random() * 0.015, // Much more subtle
|
|
pulseSpeed: 0.005 + Math.random() * 0.01, // Very slow
|
|
pulseOffset: Math.random() * Math.PI * 10 // Large offset to prevent sync
|
|
});
|
|
}
|
|
|
|
starInitialized = true;
|
|
console.log(`⭐ Created ${starEntities.length} stars, ${nebulaEntities.length} nebulae`);
|
|
}
|
|
|
|
/**
|
|
* Create a shooting star with realistic properties
|
|
*/
|
|
function createShootingStar(width, height) {
|
|
const angle = Math.PI * 0.2 + Math.random() * Math.PI * 0.3; // 35-75 degree angle
|
|
const speed = 400 + Math.random() * 500;
|
|
const isLong = Math.random() < 0.3; // 30% are longer, more dramatic
|
|
|
|
return {
|
|
x: Math.random() * width * 0.8, // Start more to the left
|
|
y: Math.random() * height * 0.3, // Start in upper portion
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
length: isLong ? 60 + Math.random() * 80 : 25 + Math.random() * 40,
|
|
life: 1.0,
|
|
decay: isLong ? 0.4 + Math.random() * 0.3 : 0.7 + Math.random() * 0.6,
|
|
brightness: 0.8 + Math.random() * 0.2,
|
|
// Trail fragments for realism
|
|
fragments: [],
|
|
lastFragmentTime: 0,
|
|
fragmentInterval: 30 + Math.random() * 20,
|
|
// Terminal flare (brightens before dying)
|
|
willFlare: Math.random() < 0.4,
|
|
flareIntensity: 1.5 + Math.random() * 1.0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get or create cached gradient
|
|
*/
|
|
function getGradient(ctx, height) {
|
|
const colorTheme = getSetting("ChristmasTheme.Background.ColorTheme") || "classic";
|
|
|
|
if (cachedGradient && cachedTheme === colorTheme && cachedHeight === height) {
|
|
return cachedGradient;
|
|
}
|
|
|
|
const theme = BACKGROUND_THEMES[colorTheme] || BACKGROUND_THEMES.classic;
|
|
if (!theme) {
|
|
console.warn("No theme found for", colorTheme);
|
|
return null;
|
|
}
|
|
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
|
gradient.addColorStop(0, theme.top || '#05004c');
|
|
gradient.addColorStop(0.5, theme.bottom || '#110E19');
|
|
gradient.addColorStop(1, theme.bottom || '#110E19');
|
|
|
|
cachedGradient = gradient;
|
|
cachedTheme = colorTheme;
|
|
cachedHeight = height;
|
|
|
|
return gradient;
|
|
}
|
|
|
|
/**
|
|
* Get a color for background snowflakes based on current settings
|
|
*/
|
|
function getBgSnowflakeColor() {
|
|
const colorScheme = getSetting("ChristmasTheme.Snowflake.ColorScheme") as string;
|
|
const christmasColors = getSetting("ChristmasTheme.ChristmasEffects.ColorScheme") as string;
|
|
|
|
switch (colorScheme) {
|
|
case "blue":
|
|
const blueVariants = ['#d4f1f9', '#c8e8f0', '#b8dce8'];
|
|
return blueVariants[Math.floor(Math.random() * blueVariants.length)];
|
|
case "rainbow":
|
|
const rainbowPalette = ['#ffb3ba', '#bae1ff', '#baffc9', '#ffffba', '#ffdfba'];
|
|
return rainbowPalette[Math.floor(Math.random() * rainbowPalette.length)];
|
|
case "match":
|
|
const selectedPalette = COLOR_SCHEMES[christmasColors] || COLOR_SCHEMES.traditional;
|
|
return selectedPalette[Math.floor(Math.random() * selectedPalette.length)];
|
|
case "newyear":
|
|
return COLOR_SCHEMES.newyear[Math.floor(Math.random() * 5)];
|
|
default:
|
|
const whiteVariants = ['#ffffff', '#f8f9fa', '#f1f3f5'];
|
|
return whiteVariants[Math.floor(Math.random() * whiteVariants.length)];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize background snowflakes for depth effect (drawn on canvas behind nodes)
|
|
*/
|
|
function initBgSnowflakes(width, height) {
|
|
bgSnowflakeEntities = [];
|
|
const count = 45; // Match approximate foreground count
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
// Mix of sizes - some small (distant), some larger (closer but still behind)
|
|
const sizeRoll = Math.random();
|
|
let size, opacity;
|
|
|
|
if (sizeRoll < 0.5) {
|
|
// Smaller, more distant flakes (50%)
|
|
size = 2 + Math.random() * 3;
|
|
opacity = 0.25 + Math.random() * 0.2;
|
|
} else if (sizeRoll < 0.85) {
|
|
// Medium flakes (35%)
|
|
size = 4 + Math.random() * 3;
|
|
opacity = 0.35 + Math.random() * 0.25;
|
|
} else {
|
|
// Larger flakes, still behind nodes (15%)
|
|
size = 6 + Math.random() * 3;
|
|
opacity = 0.4 + Math.random() * 0.2;
|
|
}
|
|
|
|
bgSnowflakeEntities.push({
|
|
x: Math.random() * width,
|
|
y: Math.random() * height, // Distribute across full height initially
|
|
size: size,
|
|
opacity: opacity,
|
|
color: getBgSnowflakeColor(), // Match color scheme
|
|
speed: 8 + Math.random() * 10, // Pixels per second (slower)
|
|
drift: (Math.random() - 0.5) * 25, // Horizontal drift amplitude
|
|
driftSpeed: 0.1 + Math.random() * 0.15, // Drift oscillation speed
|
|
driftOffset: Math.random() * Math.PI * 2, // Phase offset
|
|
rotation: Math.random() * Math.PI * 2, // Initial rotation
|
|
rotationSpeed: (Math.random() - 0.5) * 0.3, // Slow rotation
|
|
flakeType: ['branched', 'minimal', 'stellar', 'emoji1', 'emoji2', 'emoji3', 'dendrite', 'ornate'][Math.floor(Math.random() * 8)] // Random type
|
|
});
|
|
}
|
|
bgSnowflakesInitialized = true;
|
|
console.log(`❄️ Created ${count} background canvas snowflakes`);
|
|
}
|
|
|
|
/**
|
|
* Draw a 6-pointed snowflake shape at the given position
|
|
*/
|
|
function drawSnowflake(ctx, x, y, size, rotation, glowAmount = 0, color = null) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
// Apply glow inside save/restore
|
|
if (glowAmount > 0 && color) {
|
|
ctx.shadowBlur = glowAmount;
|
|
ctx.shadowColor = color;
|
|
}
|
|
|
|
// Draw 6 spokes
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.rotate(Math.PI / 3);
|
|
|
|
// Main spoke
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Small branches on each spoke (if size is large enough)
|
|
if (size > 4) {
|
|
const branchLen = size * 0.35;
|
|
const branchPos = size * 0.55;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -branchPos);
|
|
ctx.lineTo(-branchLen * 0.5, -branchPos - branchLen * 0.5);
|
|
ctx.moveTo(0, -branchPos);
|
|
ctx.lineTo(branchLen * 0.5, -branchPos - branchLen * 0.5);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.15, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw a minimal alternate snowflake - simple arms with single branch pair
|
|
*/
|
|
function drawCrystalSnowflake(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
// Draw 6 main arms with single branch pair each
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Single pair of branches at 55% height
|
|
if (size > 3) {
|
|
const branchY = -size * 0.55;
|
|
const branchLen = size * 0.35;
|
|
const branchAngle = Math.PI / 5; // 36 degrees
|
|
|
|
// Left branch
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(-Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
// Right branch
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Small center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw a stellar dendrite snowflake - more complex with double branch layers
|
|
*/
|
|
function drawStellarSnowflake(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
// Draw 6 main arms with double branch pairs
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Two pairs of branches at different heights
|
|
const positions = [0.4, 0.7];
|
|
for (const pos of positions) {
|
|
const branchY = -size * pos;
|
|
const branchLen = size * (0.45 - pos * 0.3);
|
|
const branchAngle = Math.PI / 4;
|
|
|
|
// Left branch
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(-Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
// Right branch
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Small tip accent
|
|
if (size > 4) {
|
|
ctx.beginPath();
|
|
ctx.arc(0, -size, size * 0.08, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.12, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw emoji-style snowflake ❄ (U+2744) - Classic with serif tips and symmetric side branches
|
|
*/
|
|
function drawEmoji2744(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm with slight thickening at base
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -size * 0.1);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Serif/arrow tip at end of arm (like the emoji)
|
|
const tipSize = size * 0.15;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-tipSize, -size + tipSize * 0.7);
|
|
ctx.lineTo(0, -size);
|
|
ctx.lineTo(tipSize, -size + tipSize * 0.7);
|
|
ctx.stroke();
|
|
|
|
// Side branches - single pair at 60% height, angled outward
|
|
const branchY = -size * 0.6;
|
|
const branchLen = size * 0.3;
|
|
const angle = Math.PI / 4; // 45 degrees
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(-Math.sin(angle) * branchLen, branchY - Math.cos(angle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(Math.sin(angle) * branchLen, branchY - Math.cos(angle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.08, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw emoji-style snowflake ❅ (U+2745) - Simple with pronounced center circle
|
|
*/
|
|
function drawEmoji2745(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
// Pronounced center circle (like the emoji)
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.18, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// 6 straight arms extending from center circle
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 2;
|
|
const startR = size * 0.18;
|
|
const endR = size;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.cos(angle) * startR, Math.sin(angle) * startR);
|
|
ctx.lineTo(Math.cos(angle) * endR, Math.sin(angle) * endR);
|
|
ctx.stroke();
|
|
|
|
// Small perpendicular cross at 65% length
|
|
const crossPos = size * 0.65;
|
|
const crossLen = size * 0.12;
|
|
const px = Math.cos(angle) * crossPos;
|
|
const py = Math.sin(angle) * crossPos;
|
|
const perpAngle = angle + Math.PI / 2;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(px - Math.cos(perpAngle) * crossLen, py - Math.sin(perpAngle) * crossLen);
|
|
ctx.lineTo(px + Math.cos(perpAngle) * crossLen, py + Math.sin(perpAngle) * crossLen);
|
|
ctx.stroke();
|
|
|
|
// Small dot at tip
|
|
ctx.beginPath();
|
|
ctx.arc(Math.cos(angle) * endR, Math.sin(angle) * endR, size * 0.04, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw emoji-style snowflake ❆ (U+2746) - Heavy/bold with arrow-style arms
|
|
*/
|
|
function drawEmoji2746(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
// Use slightly thicker lines for bold look
|
|
const origWidth = ctx.lineWidth;
|
|
ctx.lineWidth = origWidth * 1.3;
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -size * 0.12);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Bold arrow/chevron shape pointing outward (the distinctive feature of ❆)
|
|
const chevY = -size * 0.5;
|
|
const chevLen = size * 0.28;
|
|
const chevAngle = Math.PI / 5; // ~36 degrees
|
|
|
|
// Draw as connected chevron
|
|
ctx.beginPath();
|
|
ctx.moveTo(-Math.sin(chevAngle) * chevLen, chevY + Math.cos(chevAngle) * chevLen * 0.5);
|
|
ctx.lineTo(0, chevY);
|
|
ctx.lineTo(Math.sin(chevAngle) * chevLen, chevY + Math.cos(chevAngle) * chevLen * 0.5);
|
|
ctx.stroke();
|
|
|
|
// Small arrow tip at arm end
|
|
const tipY = -size;
|
|
const tipLen = size * 0.12;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-tipLen * 0.6, tipY + tipLen);
|
|
ctx.lineTo(0, tipY);
|
|
ctx.lineTo(tipLen * 0.6, tipY + tipLen);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.lineWidth = origWidth;
|
|
|
|
// Bold center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw a dendrite-style snowflake - fernlike with multiple branch levels
|
|
*/
|
|
function drawDendriteSnowflake(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -size);
|
|
ctx.stroke();
|
|
|
|
// Three branch pairs at different heights
|
|
const positions = [0.4, 0.6, 0.8];
|
|
positions.forEach((pos, idx) => {
|
|
const branchY = -size * pos;
|
|
const branchLen = size * (0.3 - idx * 0.06);
|
|
const branchAngle = Math.PI / 4;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(-Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.08, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw an ornate-style snowflake - decorative with diamond tips
|
|
*/
|
|
function drawOrnateSnowflake(ctx, x, y, size, rotation) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(rotation);
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
ctx.save();
|
|
ctx.rotate((Math.PI / 3) * i);
|
|
|
|
// Main arm
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(0, -size * 0.9);
|
|
ctx.stroke();
|
|
|
|
// Diamond tip
|
|
const tipY = -size * 0.9;
|
|
const dSize = size * 0.1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, tipY - dSize);
|
|
ctx.lineTo(dSize * 0.6, tipY);
|
|
ctx.lineTo(0, tipY + dSize);
|
|
ctx.lineTo(-dSize * 0.6, tipY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Branches at 50%
|
|
const branchY = -size * 0.5;
|
|
const branchLen = size * 0.3;
|
|
const branchAngle = Math.PI / 3;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(-Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, branchY);
|
|
ctx.lineTo(Math.sin(branchAngle) * branchLen, branchY - Math.cos(branchAngle) * branchLen);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// Center dot
|
|
ctx.beginPath();
|
|
ctx.arc(0, 0, size * 0.1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Draw the enhanced background onto the canvas context
|
|
*/
|
|
function drawEnhancedBackground(ctx, width, height) {
|
|
if (!isPageVisible) return;
|
|
if (!getSetting("ChristmasTheme.Background.Enabled")) return;
|
|
|
|
const now = performance.now();
|
|
|
|
// Check for resume from execution to prevent time jumps
|
|
// If we just finished executing (or cooldown), reset lastAnimationTime to now
|
|
if (!isExecuting && (now - lastAnimationTime) > 1000) {
|
|
lastAnimationTime = now;
|
|
}
|
|
|
|
// When executing, freeze deltaTime and animationTime to pause all animations
|
|
const deltaTime = isExecuting ? 0 : (now - lastAnimationTime) / 1000;
|
|
|
|
if (!isExecuting) {
|
|
lastAnimationTime = now;
|
|
animationTime += deltaTime; // Only advance animation time when not executing
|
|
}
|
|
|
|
// FPS tracking (skip during execution to preserve frame counters)
|
|
if (!isExecuting) {
|
|
frameCount++;
|
|
if (now - lastFpsCheck >= 1000) {
|
|
currentFps = frameCount;
|
|
frameCount = 0;
|
|
lastFpsCheck = now;
|
|
|
|
// Hysteresis for low perf mode to prevent flickering
|
|
if (currentFps < 28) lowPerfMode = true;
|
|
if (currentFps > 32) lowPerfMode = false;
|
|
}
|
|
}
|
|
|
|
// Ensure clean state
|
|
ctx.shadowBlur = 0;
|
|
|
|
// 500ms cooldown after execution ends to prevent reinitialization from canvas size changes
|
|
const inCooldown = (now - executionEndTime) < 500;
|
|
|
|
// Initialize stars if needed (but don't reinitialize during execution or cooldown)
|
|
// CRITICAL FIX: Do NOT reinitialize stars on resize! Canvas resize happens BEFORE
|
|
// execution_start event fires, causing stars to "vanish" momentarily.
|
|
if (!isExecuting && !inCooldown && !starInitialized) {
|
|
cachedWidth = width;
|
|
cachedHeight = height;
|
|
initStars(width, height);
|
|
}
|
|
|
|
// Initialize background snowflakes if snow is enabled
|
|
// CRITICAL FIX: Do NOT reinitialize snowflakes on resize! They will just wrap naturally.
|
|
// Reinitializing clears them, causing "disappearing" effect during resize storms.
|
|
const snowEnabled = getSetting("ChristmasTheme.Snowflake.Enabled");
|
|
if (!isExecuting && !inCooldown && snowEnabled && (!bgSnowflakesInitialized)) {
|
|
initBgSnowflakes(width, height);
|
|
}
|
|
|
|
// Check for color scheme changes and update all snowflake colors
|
|
if (snowEnabled && bgSnowflakeEntities.length > 0) {
|
|
const currentSnowflakeColorScheme = getSetting("ChristmasTheme.Snowflake.ColorScheme") as string;
|
|
const currentChristmasColorScheme = getSetting("ChristmasTheme.ChristmasEffects.ColorScheme") as string;
|
|
|
|
if (currentSnowflakeColorScheme !== lastBgSnowflakeColorScheme ||
|
|
(currentSnowflakeColorScheme === "match" && currentChristmasColorScheme !== lastBgColorScheme)) {
|
|
updateBgSnowflakeColors();
|
|
lastBgSnowflakeColorScheme = currentSnowflakeColorScheme;
|
|
lastBgColorScheme = currentChristmasColorScheme;
|
|
}
|
|
}
|
|
|
|
// Draw gradient background
|
|
ctx.save();
|
|
const gradient = getGradient(ctx, height);
|
|
if (gradient) {
|
|
ctx.fillStyle = gradient;
|
|
ctx.globalAlpha = 0.3; // Subtle overlay (restored to original value)
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
|
|
|
|
// Draw stars by layer for proper depth effect (if enabled)
|
|
const starsEnabled = getSetting("ChristmasTheme.Background.Stars");
|
|
const partyMode = getSetting("ChristmasTheme.Background.PartyMode");
|
|
const colorTheme = getSetting("ChristmasTheme.Background.ColorTheme") || "classic";
|
|
const theme = BACKGROUND_THEMES[colorTheme] || BACKGROUND_THEMES.classic;
|
|
const time = animationTime; // Use frozen animation time so effects pause during execution
|
|
|
|
// Party mode color palette (vibrant rave colors)
|
|
const partyColors = ['#ff0080', '#00ff80', '#8000ff', '#ff8000', '#00ffff', '#ff00ff', '#ffff00', '#00ff00'];
|
|
|
|
if (starsEnabled) {
|
|
for (let starIdx = 0; starIdx < starEntities.length; starIdx++) {
|
|
const star = starEntities[starIdx];
|
|
// Deterministic skip in low perf mode (skip based on index, not random)
|
|
if (lowPerfMode) {
|
|
if (star.layer === 'distant' && (starIdx % 3 !== 0)) continue;
|
|
if (star.layer === 'normal' && (starIdx % 2 !== 0)) continue;
|
|
}
|
|
|
|
// Calculate twinkle with per-star variation to prevent sync
|
|
let starSpeed = star.twinkleSpeed * star.twinkleSpeedMod;
|
|
let opacity, starColor;
|
|
|
|
if (partyMode) {
|
|
// Party mode: rapid twinkle and color cycling
|
|
starSpeed *= 8; // Much faster twinkle
|
|
const fastTwinkle = Math.sin(time * starSpeed + star.twinkleOffset);
|
|
const colorIndex = Math.floor((time * 3 + star.twinkleOffset) % partyColors.length);
|
|
starColor = partyColors[colorIndex];
|
|
opacity = star.baseOpacity * (0.4 + Math.abs(fastTwinkle) * 0.6);
|
|
} else {
|
|
// Normal mode
|
|
const twinkle = Math.sin(time * starSpeed + star.twinkleOffset);
|
|
const twinkle2 = Math.sin(time * starSpeed * 0.67 + star.twinkleOffset2);
|
|
const combinedTwinkle = (twinkle * 0.6 + twinkle2 * 0.4);
|
|
opacity = star.baseOpacity * (0.8 + combinedTwinkle * 0.2); // Subtle 20% variation
|
|
starColor = star.color;
|
|
}
|
|
|
|
// Use star's color (or party color)
|
|
ctx.fillStyle = starColor;
|
|
|
|
// Apply layer-based rendering
|
|
if (star.hasGlow && !lowPerfMode) {
|
|
// Bright stars get a subtle glow (enhanced in party mode)
|
|
ctx.shadowBlur = partyMode ? star.size * 10 : star.size * 5;
|
|
ctx.shadowColor = starColor;
|
|
ctx.globalAlpha = opacity * (partyMode ? 0.9 : 0.7);
|
|
ctx.beginPath();
|
|
ctx.arc(star.x, star.y, star.size * (partyMode ? 1.3 : 1), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Draw diffraction spikes on some bright stars - subtle and thin (skip in party mode)
|
|
if (star.hasSpikes && !partyMode) {
|
|
const combinedTwinkle = Math.sin(time * starSpeed + star.twinkleOffset);
|
|
ctx.shadowBlur = 0;
|
|
const spikeLength = star.size * 4 * (0.6 + combinedTwinkle * 0.4);
|
|
ctx.strokeStyle = starColor;
|
|
ctx.lineWidth = 0.3; // Thinner spikes
|
|
ctx.globalAlpha = opacity * 0.25; // More subtle
|
|
|
|
// 4-point cross spikes
|
|
ctx.beginPath();
|
|
ctx.moveTo(star.x - spikeLength, star.y);
|
|
ctx.lineTo(star.x + spikeLength, star.y);
|
|
ctx.moveTo(star.x, star.y - spikeLength);
|
|
ctx.lineTo(star.x, star.y + spikeLength);
|
|
ctx.stroke();
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
} else {
|
|
// Regular stars - simple circles
|
|
ctx.globalAlpha = opacity * (partyMode ? 0.7 : 0.5);
|
|
ctx.beginPath();
|
|
ctx.arc(star.x, star.y, star.size * (partyMode ? 1.2 : 1), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Atmospheric glow at horizon (subtle light pollution effect)
|
|
if (!lowPerfMode) {
|
|
const horizonGlow = ctx.createLinearGradient(0, height * 0.7, 0, height);
|
|
horizonGlow.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
|
horizonGlow.addColorStop(0.5, 'rgba(20, 30, 60, 0.05)');
|
|
horizonGlow.addColorStop(1, 'rgba(40, 50, 80, 0.1)');
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = horizonGlow;
|
|
ctx.fillRect(0, height * 0.7, width, height * 0.3);
|
|
}
|
|
|
|
// Draw nebula clouds (soft glowing areas) - skip in low perf mode
|
|
if (!lowPerfMode && nebulaEntities.length > 0) {
|
|
for (const nebula of nebulaEntities) {
|
|
// Static opacity to prevent quantization flicker at low alpha values
|
|
const nebulaOpacity = nebula.opacity;
|
|
|
|
// Create radial gradient for soft nebula effect
|
|
const nebulaGradient = ctx.createRadialGradient(
|
|
nebula.x, nebula.y, 0,
|
|
nebula.x, nebula.y, nebula.radius
|
|
);
|
|
|
|
// More subtle, lower opacity nebula colors
|
|
nebulaGradient.addColorStop(0, `rgba(80, 120, 200, ${nebulaOpacity * 0.6})`);
|
|
nebulaGradient.addColorStop(0.4, `rgba(60, 100, 180, ${nebulaOpacity * 0.3})`);
|
|
nebulaGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
|
|
ctx.globalAlpha = 1;
|
|
ctx.fillStyle = nebulaGradient;
|
|
ctx.beginPath();
|
|
ctx.arc(nebula.x, nebula.y, nebula.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Draw background snowflakes (canvas-based, behind nodes)
|
|
if (snowEnabled && bgSnowflakeEntities.length > 0) {
|
|
ctx.lineCap = 'round';
|
|
const glowIntensity = getSetting("ChristmasTheme.Snowflake.Glow") || 10;
|
|
|
|
for (const flake of bgSnowflakeEntities) {
|
|
// Update position
|
|
flake.y += flake.speed * deltaTime;
|
|
|
|
// Update rotation
|
|
flake.rotation += flake.rotationSpeed * deltaTime;
|
|
|
|
// Horizontal drift using sine wave
|
|
const driftX = Math.sin(time * flake.driftSpeed + flake.driftOffset) * flake.drift;
|
|
|
|
// Wrap around when off screen (only if not executing/frozen)
|
|
if (!isExecuting && flake.y > height + 20) {
|
|
flake.y = -20;
|
|
flake.x = Math.random() * width;
|
|
flake.color = getBgSnowflakeColor(); // Get new color on wrap
|
|
flake.flakeType = ['branched', 'minimal', 'stellar', 'emoji1', 'emoji2', 'emoji3', 'dendrite', 'ornate'][Math.floor(Math.random() * 8)]; // Random new type
|
|
}
|
|
|
|
// Draw the snowflake shape with color and glow
|
|
ctx.globalAlpha = flake.opacity;
|
|
ctx.strokeStyle = flake.color;
|
|
ctx.fillStyle = flake.color;
|
|
ctx.lineWidth = Math.max(0.5, flake.size * 0.12);
|
|
|
|
// Draw subtle glow (matching CSS drop-shadow appearance)
|
|
const glowAmount = Math.min(glowIntensity * 0.4, 8);
|
|
if (glowAmount > 0.5) {
|
|
// Convert hex color to rgba for canvas compatibility
|
|
const hexToRgba = (hex, alpha) => {
|
|
const r = parseInt(hex.slice(1, 3), 16);
|
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
};
|
|
|
|
const glowRadius = flake.size * 0.6 + glowAmount;
|
|
const gradient = ctx.createRadialGradient(
|
|
flake.x + driftX, flake.y, flake.size * 0.1,
|
|
flake.x + driftX, flake.y, glowRadius
|
|
);
|
|
gradient.addColorStop(0, hexToRgba(flake.color, 0.4));
|
|
gradient.addColorStop(0.5, hexToRgba(flake.color, 0.15));
|
|
gradient.addColorStop(1, hexToRgba(flake.color, 0));
|
|
ctx.globalAlpha = flake.opacity;
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.arc(flake.x + driftX, flake.y, glowRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Reset for snowflake
|
|
ctx.fillStyle = flake.color;
|
|
}
|
|
|
|
// Draw snowflake using appropriate style
|
|
const snowflakeType = getSetting("ChristmasTheme.Snowflake.Type");
|
|
|
|
// Check/Load custom snowflake if needed
|
|
if (snowflakeType === 'custom' || snowflakeType === 'mix_custom') {
|
|
const snowSrc = getSetting("ChristmasTheme.Snowflake.CustomImage") as string;
|
|
if (snowSrc && snowSrc !== customSnowImageSrc) {
|
|
customSnowImageSrc = snowSrc;
|
|
customSnowImage = new Image();
|
|
customSnowImage.onload = () => {
|
|
if (app.canvas) app.canvas.setDirty(true, true);
|
|
};
|
|
customSnowImage.src = snowSrc;
|
|
}
|
|
}
|
|
|
|
// Determine effective type for this specific flake
|
|
let effectiveType = snowflakeType;
|
|
if (snowflakeType === 'mix_custom') {
|
|
// 50% chance of being custom, otherwise leave as 'mix_custom' to fall through
|
|
// Use flake.x as a stable seed to avoid flickering
|
|
if (Math.floor(flake.x) % 2 === 0) {
|
|
effectiveType = 'custom';
|
|
}
|
|
}
|
|
|
|
if (effectiveType === 'custom') {
|
|
if (customSnowImage && customSnowImage.complete && customSnowImage.naturalWidth > 0) {
|
|
ctx.save();
|
|
ctx.translate(flake.x + driftX, flake.y);
|
|
ctx.rotate(flake.rotation);
|
|
const size = flake.size * 2; // Slightly larger for images
|
|
ctx.drawImage(customSnowImage, -size / 2, -size / 2, size, size);
|
|
ctx.restore();
|
|
} else {
|
|
// Fallback to minimal
|
|
drawCrystalSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
}
|
|
} else if (effectiveType && effectiveType !== 'random' && effectiveType !== 'mix_custom') {
|
|
// Specific type selected (override random assignment)
|
|
if (snowflakeType === 'classic') drawCrystalSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
else if (snowflakeType === 'simple') drawEmoji2745(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
else if (snowflakeType === 'bold') drawEmoji2746(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
else drawCrystalSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else {
|
|
// Random mix (use type assigned to flake)
|
|
if (flake.flakeType === 'minimal') {
|
|
drawCrystalSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'stellar') {
|
|
drawStellarSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'emoji1') {
|
|
drawEmoji2744(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'emoji2') {
|
|
drawEmoji2745(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'emoji3') {
|
|
drawEmoji2746(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'dendrite') {
|
|
drawDendriteSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else if (flake.flakeType === 'ornate') {
|
|
drawOrnateSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
} else {
|
|
drawSnowflake(ctx, flake.x + driftX, flake.y, flake.size, flake.rotation);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shooting stars - occasional spectacular effect (when enabled)
|
|
const shootingStarsEnabled = getSetting("ChristmasTheme.Background.ShootingStars");
|
|
if (!lowPerfMode && shootingStarsEnabled) {
|
|
// Spawn new shooting star occasionally (average every 10 seconds for calmer feel)
|
|
// Don't spawn new ones during execution to prevent accumulation
|
|
if (!isExecuting && now - lastShootingStarTime > 10000 && Math.random() < 0.02) {
|
|
shootingStars.push(createShootingStar(width, height));
|
|
lastShootingStarTime = now;
|
|
}
|
|
|
|
// Update and draw shooting stars
|
|
ctx.lineCap = 'round';
|
|
for (let i = shootingStars.length - 1; i >= 0; i--) {
|
|
const star = shootingStars[i];
|
|
|
|
// Update position
|
|
star.x += star.vx * deltaTime;
|
|
star.y += star.vy * deltaTime;
|
|
star.life -= star.decay * deltaTime;
|
|
|
|
// Remove dead stars
|
|
if (star.life <= 0 || star.x > width + 50 || star.y > height + 50) {
|
|
shootingStars.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Calculate direction vector
|
|
const speed = Math.sqrt(star.vx * star.vx + star.vy * star.vy);
|
|
const dirX = star.vx / speed;
|
|
const dirY = star.vy / speed;
|
|
|
|
// Terminal flare effect
|
|
let intensityMod = 1.0;
|
|
if (star.willFlare && star.life < 0.3) {
|
|
intensityMod = star.flareIntensity * (1 - star.life / 0.3) * star.life * 3;
|
|
}
|
|
|
|
// Draw trail fragments (small particles behind)
|
|
if (now - star.lastFragmentTime > star.fragmentInterval && star.fragments.length < 8) {
|
|
star.fragments.push({
|
|
x: star.x - dirX * 3,
|
|
y: star.y - dirY * 3,
|
|
life: 0.5,
|
|
size: 0.3 + Math.random() * 0.4
|
|
});
|
|
star.lastFragmentTime = now;
|
|
}
|
|
|
|
// Update and draw fragments
|
|
for (let j = star.fragments.length - 1; j >= 0; j--) {
|
|
const frag = star.fragments[j];
|
|
frag.life -= deltaTime * 2;
|
|
if (frag.life <= 0) {
|
|
star.fragments.splice(j, 1);
|
|
continue;
|
|
}
|
|
ctx.fillStyle = `rgba(255, 200, 150, ${frag.life * 0.4})`;
|
|
ctx.globalAlpha = frag.life;
|
|
ctx.beginPath();
|
|
ctx.arc(frag.x, frag.y, frag.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Draw main trail with warm-to-white gradient
|
|
const tailX = star.x - dirX * star.length * star.life;
|
|
const tailY = star.y - dirY * star.length * star.life;
|
|
|
|
const trailGradient = ctx.createLinearGradient(tailX, tailY, star.x, star.y);
|
|
trailGradient.addColorStop(0, 'rgba(255, 180, 100, 0)');
|
|
trailGradient.addColorStop(0.4, `rgba(255, 200, 150, ${star.life * star.brightness * 0.2 * intensityMod})`);
|
|
trailGradient.addColorStop(0.8, `rgba(255, 240, 220, ${star.life * star.brightness * 0.5 * intensityMod})`);
|
|
trailGradient.addColorStop(1, `rgba(255, 255, 255, ${star.life * star.brightness * 0.9 * intensityMod})`);
|
|
|
|
ctx.strokeStyle = trailGradient;
|
|
ctx.lineWidth = 1 + star.life * 1.5;
|
|
ctx.globalAlpha = star.life;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tailX, tailY);
|
|
ctx.lineTo(star.x, star.y);
|
|
ctx.stroke();
|
|
|
|
// Glowing head with warm core
|
|
const headSize = (1.2 + star.life) * intensityMod;
|
|
ctx.shadowBlur = 10 * intensityMod;
|
|
ctx.shadowColor = '#ffffcc';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.globalAlpha = star.life * star.brightness * intensityMod;
|
|
ctx.beginPath();
|
|
ctx.arc(star.x, star.y, headSize, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
}
|
|
|
|
// Fireworks effect (when enabled, during finale, OR during grace period with existing particles)
|
|
const hasExistingParticles = fireworkRockets.length > 0 || fireworkParticles.length > 0 || fireworkSparks.length > 0;
|
|
const fireworksEnabled = getSetting("ChristmasTheme.Background.Fireworks") || finaleActive || (finaleGracePeriod && hasExistingParticles);
|
|
if (fireworksEnabled) {
|
|
// Check if finale is still active
|
|
if (finaleActive && now - finaleStartTime > FINALE_DURATION) {
|
|
finaleActive = false;
|
|
finaleGracePeriod = true; // Enter grace period to let particles fade
|
|
console.log("🎆 Finale ended, entering grace period");
|
|
}
|
|
|
|
// End grace period when all particles have faded
|
|
if (finaleGracePeriod && !hasExistingParticles) {
|
|
finaleGracePeriod = false;
|
|
console.log("🎆 Grace period complete");
|
|
}
|
|
|
|
// Spawn rate depends on finale state - but DON'T spawn during grace period or execution
|
|
const shouldSpawn = !isExecuting && (finaleActive || (getSetting("ChristmasTheme.Background.Fireworks") && !finaleGracePeriod));
|
|
if (shouldSpawn) {
|
|
const spawnInterval = finaleActive ? 100 + Math.random() * 150 : 2000 + Math.random() * 2000;
|
|
if (now - lastFireworkTime > spawnInterval) {
|
|
const spawnCount = finaleActive ? 2 + Math.floor(Math.random() * 3) : 1;
|
|
for (let s = 0; s < spawnCount; s++) {
|
|
const rocket = createFireworkRocket(width, height);
|
|
// Special golden fireworks during finale (30% chance)
|
|
if (finaleActive && Math.random() < 0.3) {
|
|
rocket.palette = ['#ffd700', '#ffec99', '#fff9db'];
|
|
rocket.vy = -14 - Math.random() * 6;
|
|
}
|
|
fireworkRockets.push(rocket);
|
|
}
|
|
lastFireworkTime = now;
|
|
}
|
|
}
|
|
|
|
// Update and draw rockets with enhanced trails
|
|
for (let i = fireworkRockets.length - 1; i >= 0; i--) {
|
|
const rocket = fireworkRockets[i];
|
|
|
|
// Apply gravity
|
|
rocket.vy += 0.12;
|
|
rocket.x += rocket.vx;
|
|
rocket.y += rocket.vy;
|
|
rocket.age += deltaTime;
|
|
rocket.trailTimer += deltaTime;
|
|
|
|
// Add trail points more frequently
|
|
if (rocket.trailTimer > 0.02) {
|
|
rocket.trail.push({ x: rocket.x, y: rocket.y, alpha: 1, size: rocket.size });
|
|
rocket.trailTimer = 0;
|
|
}
|
|
// Keep more trail points for longer trails
|
|
if (rocket.trail.length > 20) rocket.trail.shift();
|
|
|
|
// Explode when velocity slows
|
|
if (rocket.vy > -2 && !rocket.exploded) {
|
|
rocket.exploded = true;
|
|
fireworkParticles.push(...createExplosionParticles(rocket.x, rocket.y, rocket.palette, rocket.explosionType));
|
|
fireworkRockets.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Force explode if rocket is too old (simplified explosion for performance)
|
|
if (rocket.age > 5 && !rocket.exploded) {
|
|
rocket.exploded = true;
|
|
// Create a simpler explosion with fewer particles for forced timeouts
|
|
const simpleCount = 15 + Math.floor(Math.random() * 10); // 15-25 particles vs 50-120 normal
|
|
for (let i = 0; i < simpleCount; i++) {
|
|
const angle = (Math.PI * 2 * i) / simpleCount;
|
|
const speed = 2 + Math.random() * 2;
|
|
fireworkParticles.push({
|
|
x: rocket.x,
|
|
y: rocket.y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
color: rocket.palette[Math.floor(Math.random() * rocket.palette.length)],
|
|
alpha: 1,
|
|
size: 1.5 + Math.random(),
|
|
decay: 0.025, // Faster fade than normal
|
|
trail: [],
|
|
hasTrail: false, // No trails for performance
|
|
gravity: 0.05
|
|
});
|
|
}
|
|
fireworkRockets.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Draw enhanced rocket trail with gradient
|
|
const primaryColor = rocket.palette[0];
|
|
for (let j = 0; j < rocket.trail.length; j++) {
|
|
const point = rocket.trail[j];
|
|
const progress = j / rocket.trail.length;
|
|
const alpha = progress * 0.8;
|
|
const size = point.size * progress * 0.8;
|
|
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = primaryColor;
|
|
ctx.beginPath();
|
|
ctx.arc(point.x, point.y, size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Draw rocket head with bright glow
|
|
ctx.shadowBlur = 15;
|
|
ctx.shadowColor = primaryColor;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.globalAlpha = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(rocket.x, rocket.y, rocket.size + 1, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Inner bright core
|
|
ctx.fillStyle = primaryColor;
|
|
ctx.beginPath();
|
|
ctx.arc(rocket.x, rocket.y, rocket.size * 0.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// Update and draw explosion particles with trails
|
|
for (let i = fireworkParticles.length - 1; i >= 0; i--) {
|
|
const p = fireworkParticles[i];
|
|
|
|
// Store trail before updating position
|
|
if (p.hasTrail && p.trail) {
|
|
p.trail.push({ x: p.x, y: p.y, alpha: p.alpha });
|
|
if (p.trail.length > 8) p.trail.shift();
|
|
}
|
|
|
|
// Apply physics
|
|
p.vy += p.gravity || 0.04;
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.vx *= 0.985;
|
|
p.vy *= 0.985;
|
|
p.alpha -= p.decay;
|
|
|
|
// Handle crackle effect
|
|
if (p.crackle && p.crackleTime !== undefined) {
|
|
p.crackleTime -= deltaTime;
|
|
if (p.crackleTime <= 0 && p.alpha > 0.3) {
|
|
// Create mini explosion
|
|
for (let c = 0; c < 5; c++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = 1 + Math.random() * 2;
|
|
fireworkSparks.push({
|
|
x: p.x, y: p.y,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed,
|
|
alpha: 0.8,
|
|
size: 0.8,
|
|
decay: 0.05,
|
|
twinkle: Math.random() * Math.PI * 2
|
|
});
|
|
}
|
|
p.crackle = false;
|
|
}
|
|
}
|
|
|
|
// Remove faded
|
|
if (p.alpha <= 0) {
|
|
fireworkParticles.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Draw particle trail first (behind main particle)
|
|
if (p.hasTrail && p.trail && p.trail.length > 0) {
|
|
for (let t = 0; t < p.trail.length; t++) {
|
|
const tp = p.trail[t];
|
|
const trailAlpha = (t / p.trail.length) * p.alpha * 0.5;
|
|
const trailSize = p.size * (t / p.trail.length) * 0.7;
|
|
ctx.globalAlpha = trailAlpha;
|
|
ctx.fillStyle = p.color;
|
|
ctx.beginPath();
|
|
ctx.arc(tp.x, tp.y, trailSize, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Draw main particle with glow
|
|
ctx.shadowBlur = 8;
|
|
ctx.shadowColor = p.color;
|
|
ctx.fillStyle = p.color;
|
|
ctx.globalAlpha = p.alpha;
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
// Update and draw sparks/glitter
|
|
for (let i = fireworkSparks.length - 1; i >= 0; i--) {
|
|
const s = fireworkSparks[i];
|
|
|
|
s.vy += 0.03;
|
|
s.x += s.vx;
|
|
s.y += s.vy;
|
|
s.alpha -= s.decay;
|
|
s.twinkle += 0.3;
|
|
|
|
if (s.alpha <= 0) {
|
|
fireworkSparks.splice(i, 1);
|
|
continue;
|
|
}
|
|
|
|
// Twinkle effect
|
|
const twinkleAlpha = s.alpha * (0.5 + Math.sin(s.twinkle) * 0.5);
|
|
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.globalAlpha = twinkleAlpha;
|
|
ctx.beginPath();
|
|
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// ✨ Mouse Effects
|
|
updateMouseParticles(deltaTime);
|
|
drawMouseParticles(ctx);
|
|
|
|
// 🎆 Finale Particles (confetti & stars)
|
|
if (finaleActive || finaleConfetti.length > 0 || finaleStars.length > 0) {
|
|
updateFinaleParticles(ctx, ctx.canvas);
|
|
drawFinaleParticles(ctx);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Animation loop to keep background alive when no user interaction occours
|
|
*/
|
|
function startAnimationLoop() {
|
|
if (animationLoopId !== null) return;
|
|
|
|
function loop() {
|
|
if (isPageVisible && getSetting("ChristmasTheme.Background.Enabled")) {
|
|
// Check if we should pause during execution
|
|
const pauseDuringRender = getSetting("ChristmasTheme.PauseDuringRender");
|
|
const shouldRender = !isExecuting || !pauseDuringRender;
|
|
|
|
if (shouldRender && app.canvas) {
|
|
// Force redraw to keep animations playing
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
animationLoopId = requestAnimationFrame(loop);
|
|
}
|
|
animationLoopId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
function stopAnimationLoop() {
|
|
if (animationLoopId !== null) {
|
|
cancelAnimationFrame(animationLoopId);
|
|
animationLoopId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hook into LGraphCanvas to draw our background
|
|
*/
|
|
function installBackgroundHook() {
|
|
if (!app.canvas) {
|
|
console.log("Waiting for app.canvas to install background hook...");
|
|
setTimeout(installBackgroundHook, 100);
|
|
return;
|
|
}
|
|
|
|
const canvas = app.canvas;
|
|
|
|
// Store original method
|
|
if (!originalDrawBackCanvas) {
|
|
originalDrawBackCanvas = canvas.constructor.prototype.drawBackCanvas;
|
|
}
|
|
|
|
// Override drawBackCanvas to add our background
|
|
canvas.constructor.prototype.drawBackCanvas = function () {
|
|
// Call original first (clears background, draws grid)
|
|
if (originalDrawBackCanvas) {
|
|
originalDrawBackCanvas.apply(this, [...arguments]);
|
|
}
|
|
|
|
// Then draw our enhanced background on top (wrapped in try-catch to not break ComfyUI)
|
|
try {
|
|
if (getSetting("ChristmasTheme.Background.Enabled") && this.bgctx) {
|
|
drawEnhancedBackground(this.bgctx, this.bgcanvas.width, this.bgcanvas.height);
|
|
}
|
|
} catch (e) {
|
|
console.error("Enhanced background error:", e);
|
|
}
|
|
};
|
|
|
|
// Force a redraw and start loop
|
|
canvas.setDirty(true, true);
|
|
startAnimationLoop();
|
|
console.log("✨ Background hook installed successfully");
|
|
}
|
|
|
|
/**
|
|
* Remove background hook and restore original
|
|
*/
|
|
function removeBackgroundHook() {
|
|
stopAnimationLoop();
|
|
if (originalDrawBackCanvas && app.canvas) {
|
|
app.canvas.constructor.prototype.drawBackCanvas = originalDrawBackCanvas;
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
starEntities = [];
|
|
starInitialized = false;
|
|
cachedGradient = null;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.EnhancedBackground",
|
|
async setup() {
|
|
// Prevent double setup
|
|
if (extensionSetupComplete) {
|
|
return;
|
|
}
|
|
extensionSetupComplete = true;
|
|
|
|
console.log("🎨 Setting up Enhanced Background extension...");
|
|
|
|
// Initialize settings cache
|
|
initSettingsCache();
|
|
|
|
// Listen for workflow execution to pause animations during generation
|
|
app.api.addEventListener("execution_start", () => {
|
|
isExecuting = true;
|
|
});
|
|
app.api.addEventListener("execution_cached", () => {
|
|
// Cached execution also counts as executing
|
|
isExecuting = true;
|
|
});
|
|
app.api.addEventListener("executed", () => {
|
|
isExecuting = false;
|
|
executionEndTime = performance.now(); // Track when execution ended
|
|
});
|
|
app.api.addEventListener("execution_error", () => {
|
|
isExecuting = false;
|
|
executionEndTime = performance.now();
|
|
});
|
|
|
|
// Add settings with onChange callbacks to update cache
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.Enabled",
|
|
name: "🌟 Background Effect",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "✨ On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: true,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.Enabled", value);
|
|
if (isInitialSetup) return;
|
|
|
|
if (value) {
|
|
installBackgroundHook();
|
|
} else {
|
|
removeBackgroundHook();
|
|
}
|
|
|
|
// Force redraw
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.ColorTheme",
|
|
name: "🎨 Color Theme",
|
|
type: "combo",
|
|
options: [
|
|
{ value: "classic", text: "🌌 Classic Night" },
|
|
{ value: "christmas", text: "🎄 Christmas Forest" },
|
|
{ value: "candycane", text: "🍬 Candy Cane Red" },
|
|
{ value: "frostnight", text: "❄️ Frost Night" },
|
|
{ value: "gingerbread", text: "🍪 Gingerbread" },
|
|
{ value: "darknight", text: "🌑 Dark Night" }
|
|
],
|
|
defaultValue: "frostnight",
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.ColorTheme", value);
|
|
if (isInitialSetup) return;
|
|
|
|
// Invalidate gradient cache
|
|
cachedGradient = null;
|
|
|
|
// Force redraw
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.ShootingStars",
|
|
name: "☄️ Shooting Stars",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "✨ On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: true,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.ShootingStars", value);
|
|
if (isInitialSetup) return;
|
|
|
|
// Clear existing shooting stars if disabled
|
|
if (!value) {
|
|
shootingStars = [];
|
|
}
|
|
|
|
// Force redraw
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.Stars",
|
|
name: "⭐ Background Stars",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "✨ On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: true,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.Stars", value);
|
|
if (isInitialSetup) return;
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.PartyMode",
|
|
name: "🪩 Party Mode (Rave Stars)",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "🎉 On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: false,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.PartyMode", value);
|
|
if (isInitialSetup) return;
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.Fireworks",
|
|
name: "🎆 Fireworks",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "🎇 On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: false,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.Fireworks", value);
|
|
if (isInitialSetup) return;
|
|
// Clear existing fireworks when disabled
|
|
if (!value) {
|
|
fireworkRockets = [];
|
|
fireworkParticles = [];
|
|
}
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.MouseEffect",
|
|
name: "✨ Mouse Trail Effect",
|
|
type: "combo",
|
|
options: [
|
|
{ value: "none", text: "⭘ Off" },
|
|
{ value: "sparkler", text: "✨ Sparkler" },
|
|
{ value: "snowflake", text: "❄️ Snowflake" },
|
|
{ value: "confetti", text: "🎊 Confetti" },
|
|
{ value: "stardust", text: "⭐ Stardust" },
|
|
{ value: "comet", text: "☄️ Comet" },
|
|
{ value: "aurora", text: "🌌 Aurora" },
|
|
{ value: "ribbon", text: "🎀 Ribbon" },
|
|
{ value: "crystal", text: "💎 Crystal" },
|
|
{ value: "petals", text: "🌸 Petals" },
|
|
{ value: "gifts", text: "🎁 Gifts" },
|
|
{ value: "candy", text: "🍬 Candy" },
|
|
{ value: "orb", text: "🔮 Magic Orb" },
|
|
{ value: "magic", text: "✨ Magic Wand" },
|
|
{ value: "nova", text: "🌟 Nova" },
|
|
{ value: "bubbles", text: "💧 Bubbles" },
|
|
{ value: "embers", text: "🔥 Embers" },
|
|
{ value: "lightning", text: "⚡ Lightning" },
|
|
{ value: "leaves", text: "🍂 Leaves" },
|
|
{ value: "wishes", text: "💫 Wishes" },
|
|
{ value: "notes", text: "🎵 Notes" },
|
|
{ value: "hearts", text: "💖 Hearts" }
|
|
],
|
|
defaultValue: "none",
|
|
section: "Background Theme",
|
|
tooltip: "Choose a mouse trail effect",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.MouseEffect", value);
|
|
if (isInitialSetup) return;
|
|
if (!value || value === "none") {
|
|
mouseParticles = [];
|
|
}
|
|
if (app.canvas) {
|
|
app.canvas.setDirty(true, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
app.ui.settings.addSetting({
|
|
id: "ChristmasTheme.Background.Countdown",
|
|
name: "🎊 New Year Countdown",
|
|
type: "combo",
|
|
options: [
|
|
{ value: true, text: "🕐 On" },
|
|
{ value: false, text: "⭘ Off" }
|
|
],
|
|
defaultValue: true,
|
|
section: "Background Theme",
|
|
onChange: async (value) => {
|
|
updateCache("ChristmasTheme.Background.Countdown", value);
|
|
if (isInitialSetup) return;
|
|
toggleCountdownDisplay(value);
|
|
}
|
|
});
|
|
|
|
// Temporarily hidden - uncomment to re-enable
|
|
// app.ui.settings.addSetting({
|
|
// id: "ChristmasTheme.Background.ShowFinaleButton",
|
|
// name: "🎆 Show Finale Button",
|
|
// tooltip: "Turn on if you don't like surprises or wait till 00:00:00 for the show!",
|
|
// type: "combo",
|
|
// options: [
|
|
// { value: true, text: "🎇 Show" },
|
|
// { value: false, text: "🎁 Surprise" }
|
|
// ],
|
|
// defaultValue: false,
|
|
// section: "Background Theme",
|
|
// onChange: async (value) => {
|
|
// updateCache("ChristmasTheme.Background.ShowFinaleButton", value);
|
|
// if (isInitialSetup) return;
|
|
// if (value && getSetting("ChristmasTheme.Background.Countdown")) {
|
|
// showFinaleButton();
|
|
// } else {
|
|
// hideFinaleButton();
|
|
// }
|
|
// }
|
|
// });
|
|
|
|
// Load stored values AFTER settings are registered
|
|
loadSettingFromStorage("ChristmasTheme.Background.Enabled");
|
|
loadSettingFromStorage("ChristmasTheme.Background.ColorTheme");
|
|
loadSettingFromStorage("ChristmasTheme.Background.Stars");
|
|
loadSettingFromStorage("ChristmasTheme.Background.PartyMode");
|
|
loadSettingFromStorage("ChristmasTheme.Background.ShootingStars");
|
|
loadSettingFromStorage("ChristmasTheme.Background.Fireworks");
|
|
loadSettingFromStorage("ChristmasTheme.Background.MouseEffect");
|
|
loadSettingFromStorage("ChristmasTheme.Background.Countdown");
|
|
// loadSettingFromStorage("ChristmasTheme.Background.ShowFinaleButton"); // Temporarily hidden
|
|
|
|
// Mark initial setup complete
|
|
isInitialSetup = false;
|
|
|
|
// Install hook if enabled
|
|
if (getSetting("ChristmasTheme.Background.Enabled")) {
|
|
installBackgroundHook();
|
|
}
|
|
|
|
// Initialize countdown if enabled
|
|
if (getSetting("ChristmasTheme.Background.Countdown")) {
|
|
toggleCountdownDisplay(true);
|
|
}
|
|
|
|
// Return cleanup function
|
|
return () => {
|
|
removeBackgroundHook();
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
}
|
|
if (countdownElement) {
|
|
countdownElement.remove();
|
|
}
|
|
};
|
|
}
|
|
});
|
|
|
|
// Export visibility and execution state for other modules
|
|
export { isPageVisible, isExecuting }; |