/** * 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(); } // Spawn near-miss hazardous particle / projectile flying at avatar spawnHazardProjectile(type, startX, startY, targetX, targetY) { const dx = targetX - startX; const dy = targetY - startY; const dist = Math.sqrt(dx * dx + dy * dy); const speed = 14; this.projectiles.push({ type: type, // 'spark' or 'chemical_drop' x: startX, y: startY, vx: (dx / dist) * speed, vy: (dy / dist) * speed, targetX: targetX, targetY: targetY, size: type === 'spark' ? 7 : 12, color: type === 'spark' ? '#ff3b30' : '#00f0ff', trail: [], reached: false }); } spawnSparks(originX, originY, count = 12) { for (let i = 0; i < count; i++) { const angle = (Math.PI * 0.8) + (Math.random() * 0.9 - 0.45); // Directed left-upward arc const speed = Math.random() * 12 + 6; this.particles.push({ type: 'spark', x: originX, y: originY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - (Math.random() * 4), gravity: 0.35, life: 1.0, decay: Math.random() * 0.03 + 0.02, size: Math.random() * 3 + 1.5, color: Math.random() > 0.3 ? '#ffdd55' : '#ff5511' }); } } spawnChemicalFlow(originX, originY) { for (let i = 0; i < 4; i++) { this.particles.push({ type: 'chemical', x: originX + (Math.random() * 8 - 4), y: originY, vx: -(Math.random() * 2 + 1), vy: Math.random() * 4 + 2, gravity: 0.25, life: 1.0, decay: 0.02, size: Math.random() * 4 + 3, color: '#10b981' }); } } 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()); } }