Files
game_development_workshop/js/particles.js
T

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