223 lines
6.3 KiB
JavaScript
223 lines
6.3 KiB
JavaScript
/**
|
|
* 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());
|
|
}
|
|
}
|