/** * 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()); } }