/** * PPE SAFETY CHALLENGE - PARTICLE PHYSICS & VISUAL FX ENGINE * Renders high-velocity sparks, chemical pours, near-miss hazard projectiles, and freeze-frame effects. */ class ParticleEngine { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas ? this.canvas.getContext('2d') : null; this.particles = []; this.projectiles = []; this.confetti = []; this.isRunning = false; this.isFrozen = false; this.activeMode = null; // 'sparks', 'chemical', 'confetti' this.resize(); window.addEventListener('resize', () => this.resize()); } resize() { if (!this.canvas) return; const rect = this.canvas.parentElement.getBoundingClientRect(); this.canvas.width = rect.width; this.canvas.height = rect.height; } clear() { this.particles = []; this.projectiles = []; this.confetti = []; if (this.ctx && this.canvas) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } } setFrozen(freeze) { this.isFrozen = freeze; } startMode(mode) { this.activeMode = mode; this.isFrozen = false; if (!this.isRunning) { this.isRunning = true; this.animate(); } } stopMode() { this.activeMode = null; this.clear(); } // Particle Spawning (Disabled for clean direct feedback) spawnHazardProjectile(type, startX, startY, targetX, targetY) { // Disabled } spawnSparks(originX, originY, count = 12) { // Disabled } spawnChemicalFlow(originX, originY) { // Disabled } spawnConfetti() { this.confetti = []; const colors = ['#facc15', '#10b981', '#0ea5e9', '#ec4899', '#8b5cf6']; for (let i = 0; i < 80; i++) { this.confetti.push({ x: this.canvas.width / 2, y: this.canvas.height / 2, vx: (Math.random() - 0.5) * 16, vy: (Math.random() - 0.8) * 14, size: Math.random() * 8 + 4, color: colors[Math.floor(Math.random() * colors.length)], rotation: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.2, gravity: 0.2, life: 1.0, decay: 0.012 }); } } animate() { if (!this.isRunning) return; if (!this.isFrozen) { if (this.ctx && this.canvas) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } // Origin coordinates relative to stage const originX = this.canvas.width * 0.72; const originY = this.canvas.height * 0.65; if (this.activeMode === 'sparks') { this.spawnSparks(originX, originY, 6); } else if (this.activeMode === 'chemical') { this.spawnChemicalFlow(originX, originY); } // 1. Update & Draw Ambient Task Particles for (let i = this.particles.length - 1; i >= 0; i--) { const p = this.particles[i]; p.x += p.vx; p.y += p.vy; p.vy += p.gravity; p.life -= p.decay; if (p.life <= 0) { this.particles.splice(i, 1); continue; } this.ctx.save(); this.ctx.globalAlpha = Math.max(0, p.life); this.ctx.fillStyle = p.color; this.ctx.shadowBlur = p.type === 'spark' ? 8 : 4; this.ctx.shadowColor = p.color; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); this.ctx.fill(); this.ctx.restore(); } // 2. Update & Draw Near-Miss Projectiles (For Consequence Warning) for (let i = this.projectiles.length - 1; i >= 0; i--) { const prj = this.projectiles[i]; prj.trail.push({ x: prj.x, y: prj.y }); if (prj.trail.length > 8) prj.trail.shift(); prj.x += prj.vx; prj.y += prj.vy; // Draw glowing motion trail this.ctx.save(); this.ctx.strokeStyle = prj.color; this.ctx.lineWidth = prj.size * 0.8; this.ctx.lineCap = 'round'; this.ctx.shadowBlur = 12; this.ctx.shadowColor = prj.color; if (prj.trail.length > 1) { this.ctx.beginPath(); this.ctx.moveTo(prj.trail[0].x, prj.trail[0].y); for (let j = 1; j < prj.trail.length; j++) { this.ctx.lineTo(prj.trail[j].x, prj.trail[j].y); } this.ctx.stroke(); } // Draw head this.ctx.fillStyle = '#ffffff'; this.ctx.beginPath(); this.ctx.arc(prj.x, prj.y, prj.size, 0, Math.PI * 2); this.ctx.fill(); this.ctx.restore(); // Check if close to worker target -> trigger freeze frame const dist = Math.hypot(prj.x - prj.targetX, prj.y - prj.targetY); if (dist < 35 && !prj.reached) { prj.reached = true; // Trigger visual freeze event window.dispatchEvent(new CustomEvent('hazard-freeze-triggered', { detail: { projectile: prj } })); } } // 3. Update & Draw Victory Confetti for (let i = this.confetti.length - 1; i >= 0; i--) { const c = this.confetti[i]; c.x += c.vx; c.y += c.vy; c.vy += c.gravity; c.rotation += c.rotSpeed; c.life -= c.decay; if (c.life <= 0) { this.confetti.splice(i, 1); continue; } this.ctx.save(); this.ctx.translate(c.x, c.y); this.ctx.rotate(c.rotation); this.ctx.globalAlpha = c.life; this.ctx.fillStyle = c.color; this.ctx.fillRect(-c.size / 2, -c.size / 4, c.size, c.size / 2); this.ctx.restore(); } } else { // FROZEN STATE: Draw pulsating danger halo around the frozen near-miss particle if (this.projectiles.length > 0) { const prj = this.projectiles[0]; this.ctx.save(); const pulse = Math.sin(Date.now() * 0.01) * 8 + 18; this.ctx.strokeStyle = '#ef4444'; this.ctx.lineWidth = 3; this.ctx.setLineDash([6, 4]); this.ctx.beginPath(); this.ctx.arc(prj.x, prj.y, pulse, 0, Math.PI * 2); this.ctx.stroke(); // Threat line to avatar this.ctx.strokeStyle = 'rgba(239, 68, 68, 0.7)'; this.ctx.lineWidth = 2; this.ctx.setLineDash([4, 4]); this.ctx.beginPath(); this.ctx.moveTo(prj.x, prj.y); this.ctx.lineTo(prj.targetX, prj.targetY); this.ctx.stroke(); this.ctx.restore(); } } requestAnimationFrame(() => this.animate()); } } /** * GOLDEN PARTICLE ENGINE FOR HOME SCREEN * Generates floating golden ambient dust, sparkling stars, glowing bokeh orbs, and embers. */ class GoldenParticleEngine { constructor(canvasId) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas ? this.canvas.getContext('2d') : null; this.particles = []; this.mouse = { x: -1000, y: -1000, isHovering: false }; this.isRunning = false; this.maxParticles = 75; this.animId = null; if (this.canvas) { this.init(); } } init() { this.resize(); window.addEventListener('resize', () => this.resize()); // Interactive mouse glow and cursor sparkle trail const backdrop = this.canvas.parentElement; if (backdrop) { backdrop.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); this.mouse.x = e.clientX - rect.left; this.mouse.y = e.clientY - rect.top; this.mouse.isHovering = true; if (Math.random() < 0.4 && this.isRunning) { this.spawnCursorSparkle(this.mouse.x, this.mouse.y); } }); backdrop.addEventListener('mouseleave', () => { this.mouse.isHovering = false; this.mouse.x = -1000; this.mouse.y = -1000; }); } this.createInitialParticles(); this.start(); } resize() { if (!this.canvas) return; const rect = this.canvas.parentElement ? this.canvas.parentElement.getBoundingClientRect() : this.canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); this.canvas.width = (rect.width || 1024) * dpr; this.canvas.height = (rect.height || 458) * dpr; this.dpr = dpr; this.logicalWidth = rect.width || 1024; this.logicalHeight = rect.height || 458; } createInitialParticles() { this.particles = []; for (let i = 0; i < this.maxParticles; i++) { this.particles.push(this.createParticle(true)); } } createParticle(randomY = false) { const types = ['dust', 'dust', 'sparkle', 'orb', 'ember']; const type = types[Math.floor(Math.random() * types.length)]; const w = this.logicalWidth || 1024; const h = this.logicalHeight || 458; const palettes = [ { fill: '#ffd700', glow: '#f59e0b' }, // 24k Gold { fill: '#fef08a', glow: '#fbbf24' }, // Light Champagne Sparkle { fill: '#f59e0b', glow: '#d97706' }, // Deep Warm Amber Gold { fill: '#fffbeb', glow: '#facc15' }, // Star Core Glow { fill: '#eab308', glow: '#ca8a04' } // Lustrous Yellow Gold ]; const palette = palettes[Math.floor(Math.random() * palettes.length)]; let size, baseAlpha, speedY, speedX, wobbleSpeed; if (type === 'orb') { size = Math.random() * 4.5 + 2.5; baseAlpha = Math.random() * 0.22 + 0.08; speedY = -(Math.random() * 0.35 + 0.12); speedX = (Math.random() - 0.5) * 0.22; wobbleSpeed = Math.random() * 0.015 + 0.005; } else if (type === 'sparkle') { size = Math.random() * 3.5 + 2.2; baseAlpha = Math.random() * 0.55 + 0.35; speedY = -(Math.random() * 0.6 + 0.25); speedX = (Math.random() - 0.5) * 0.35; wobbleSpeed = Math.random() * 0.03 + 0.01; } else if (type === 'ember') { size = Math.random() * 2.2 + 1.2; baseAlpha = Math.random() * 0.75 + 0.25; speedY = -(Math.random() * 0.85 + 0.35); speedX = (Math.random() - 0.5) * 0.4; wobbleSpeed = Math.random() * 0.04 + 0.015; } else { // dust size = Math.random() * 1.8 + 0.8; baseAlpha = Math.random() * 0.45 + 0.2; speedY = -(Math.random() * 0.4 + 0.15); speedX = (Math.random() - 0.5) * 0.2; wobbleSpeed = Math.random() * 0.02 + 0.008; } return { type, x: Math.random() * w, y: randomY ? Math.random() * h : h + Math.random() * 15, size, palette, baseAlpha, currentAlpha: baseAlpha, speedY, speedX, wobbleAngle: Math.random() * Math.PI * 2, wobbleSpeed, wobbleRadius: Math.random() * 20 + 8, twinkleAngle: Math.random() * Math.PI * 2, twinkleSpeed: Math.random() * 0.05 + 0.02, rot: Math.random() * Math.PI * 2, rotSpeed: (Math.random() - 0.5) * 0.025, life: 1.0, isCursorParticle: false }; } spawnCursorSparkle(x, y) { if (this.particles.length > this.maxParticles + 25) return; this.particles.push({ type: 'sparkle', x: x + (Math.random() - 0.5) * 14, y: y + (Math.random() - 0.5) * 14, size: Math.random() * 3 + 2, palette: { fill: '#ffffff', glow: '#ffd700' }, baseAlpha: 0.95, currentAlpha: 0.95, speedY: (Math.random() - 0.7) * 1.4, speedX: (Math.random() - 0.5) * 1.4, wobbleAngle: Math.random() * Math.PI * 2, wobbleSpeed: 0.05, wobbleRadius: 6, twinkleAngle: 0, twinkleSpeed: 0.12, rot: Math.random() * Math.PI, rotSpeed: 0.06, life: 1.0, decay: 0.022, isCursorParticle: true }); } start() { if (this.isRunning) return; this.isRunning = true; this.animate(); } stop() { this.isRunning = false; if (this.animId) { cancelAnimationFrame(this.animId); this.animId = null; } } drawStarSparkle(ctx, x, y, size, rot, alpha, palette) { ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.globalAlpha = alpha; ctx.fillStyle = palette.fill; ctx.shadowColor = palette.glow; ctx.shadowBlur = 10; const spikes = 4; const outerRadius = size * 1.9; const innerRadius = size * 0.35; ctx.beginPath(); for (let i = 0; i < spikes * 2; i++) { const r = (i % 2 === 0) ? outerRadius : innerRadius; const angle = (i * Math.PI) / spikes; const sx = Math.cos(angle) * r; const sy = Math.sin(angle) * r; if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); } ctx.closePath(); ctx.fill(); // Inner bright core ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(0, 0, innerRadius * 0.75, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } animate() { if (!this.isRunning) return; const w = this.logicalWidth || 1024; const h = this.logicalHeight || 458; const dpr = this.dpr || 1; if (this.ctx && this.canvas) { this.ctx.save(); this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.ctx.clearRect(0, 0, w, h); for (let i = this.particles.length - 1; i >= 0; i--) { const p = this.particles[i]; // Motion & Oscillation p.wobbleAngle += p.wobbleSpeed; p.twinkleAngle += p.twinkleSpeed; p.rot += p.rotSpeed; p.y += p.speedY; p.x += p.speedX + Math.sin(p.wobbleAngle) * 0.4; // Twinkle factor const twinkleFactor = 0.65 + 0.35 * Math.sin(p.twinkleAngle); p.currentAlpha = p.baseAlpha * twinkleFactor; // Cursor gentle magnetic attraction / deflection if (this.mouse.isHovering) { const dx = p.x - this.mouse.x; const dy = p.y - this.mouse.y; const dist = Math.hypot(dx, dy); if (dist < 65 && dist > 1) { const force = (65 - dist) / 65 * 0.65; p.x += (dx / dist) * force; p.y += (dy / dist) * force; } } // Handle cursor-spawned decay if (p.isCursorParticle) { p.life -= p.decay || 0.02; p.currentAlpha *= Math.max(0, p.life); if (p.life <= 0) { this.particles.splice(i, 1); continue; } } // Recycle particles drifting off screen if (p.y < -25 || p.x < -30 || p.x > w + 30) { if (!p.isCursorParticle) { this.particles[i] = this.createParticle(false); } else { this.particles.splice(i, 1); } continue; } // Render particle if (p.type === 'sparkle') { this.drawStarSparkle(this.ctx, p.x, p.y, p.size, p.rot, Math.min(1, p.currentAlpha), p.palette); } else if (p.type === 'orb') { // Soft golden bokeh orb this.ctx.save(); this.ctx.globalAlpha = Math.min(1, p.currentAlpha); this.ctx.fillStyle = p.palette.fill; this.ctx.shadowColor = p.palette.glow; this.ctx.shadowBlur = 10; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); this.ctx.fill(); // Outer translucent halo this.ctx.fillStyle = p.palette.glow; this.ctx.globalAlpha = p.currentAlpha * 0.35; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.size * 1.7, 0, Math.PI * 2); this.ctx.fill(); this.ctx.restore(); } else { // Ambient dust or ember this.ctx.save(); this.ctx.globalAlpha = Math.min(1, p.currentAlpha); this.ctx.fillStyle = p.palette.fill; this.ctx.shadowColor = p.palette.glow; this.ctx.shadowBlur = p.type === 'ember' ? 8 : 4; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); this.ctx.fill(); if (p.type === 'ember') { this.ctx.fillStyle = '#ffffff'; this.ctx.globalAlpha = p.currentAlpha * 0.85; this.ctx.beginPath(); this.ctx.arc(p.x, p.y, p.size * 0.4, 0, Math.PI * 2); this.ctx.fill(); } this.ctx.restore(); } } this.ctx.restore(); } this.animId = requestAnimationFrame(() => this.animate()); } } window.ParticleEngine = ParticleEngine; window.GoldenParticleEngine = GoldenParticleEngine;