Initial commit: Safety Game
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Operating System Files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editor / IDE settings
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Temporary / Log files
|
||||||
|
*.log
|
||||||
+1678
File diff suppressed because it is too large
Load Diff
+423
@@ -0,0 +1,423 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PPE Safety Challenge - Choose Right. Work Safe.</title>
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- App Container -->
|
||||||
|
<div id="app-root" class="app-container">
|
||||||
|
|
||||||
|
<!-- Top Navigation / Header Bar -->
|
||||||
|
<header class="top-bar">
|
||||||
|
<div class="brand-badge">
|
||||||
|
<div class="safety-cross-icon">✚</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<span class="game-title">PPE SAFETY CHALLENGE</span>
|
||||||
|
<span class="game-tagline">CHOOSE RIGHT. WORK SAFE.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hud-center">
|
||||||
|
<div class="scenario-stepper" id="scenario-stepper">
|
||||||
|
<div class="step-pill active" id="step-pill-1">
|
||||||
|
<span class="step-num">1</span>
|
||||||
|
<span class="step-label">Grinding Ops</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-connector"></div>
|
||||||
|
<div class="step-pill" id="step-pill-2">
|
||||||
|
<span class="step-num">2</span>
|
||||||
|
<span class="step-label">Chemical Handling</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hud-right">
|
||||||
|
<div class="score-display">
|
||||||
|
<span class="score-label">SAFETY SCORE</span>
|
||||||
|
<span class="score-val" id="hud-score">0</span>
|
||||||
|
<span class="score-stars" id="hud-stars">☆☆</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Controls Menu Trigger -->
|
||||||
|
<button id="btn-audio-settings" class="btn-icon" title="Audio Settings (M)" aria-label="Audio Settings">
|
||||||
|
<span id="audio-icon">🔊</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-restart" class="btn-icon" title="Restart Game (R)" aria-label="Restart">
|
||||||
|
<span>🔄</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Dynamic Screen Container -->
|
||||||
|
<main id="main-viewport" class="main-viewport">
|
||||||
|
|
||||||
|
<!-- SCREEN 1: START / INTRO SCREEN -->
|
||||||
|
<section id="screen-start" class="screen-view active">
|
||||||
|
<div class="intro-card">
|
||||||
|
<div class="hero-graphic">
|
||||||
|
<div class="safety-shield-animated">
|
||||||
|
<svg viewBox="0 0 100 100" class="shield-svg">
|
||||||
|
<polygon points="50,5 92,20 92,55 50,95 8,55 8,20" class="shield-outline" />
|
||||||
|
<path d="M50 25 L50 75 M25 50 L75 50" class="shield-plus" />
|
||||||
|
</svg>
|
||||||
|
<div class="pulse-ring"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="hero-title">PPE SAFETY CHALLENGE</h1>
|
||||||
|
<p class="hero-subtitle">“Choose Right. Work Safe.”</p>
|
||||||
|
|
||||||
|
<div class="mission-briefing">
|
||||||
|
<h3>Interactive Workplace Safety Mission</h3>
|
||||||
|
<p>Select the correct Personal Protective Equipment (PPE) for critical industrial tasks. Learn how proper gear prevents severe workplace injuries before starting dangerous operations.</p>
|
||||||
|
|
||||||
|
<div class="learning-flow-banner">
|
||||||
|
<span class="flow-item">🔍 SEE SITUATION</span>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<span class="flow-item">⚠️ IDENTIFY HAZARD</span>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<span class="flow-item">🦺 SELECT PPE</span>
|
||||||
|
<span class="flow-arrow">➔</span>
|
||||||
|
<span class="flow-item">⚙️ TEST SAFETY</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="start-controls">
|
||||||
|
<button id="btn-start-game" class="btn btn-primary btn-large glow-effect">
|
||||||
|
<span class="btn-icon-large">▶</span>
|
||||||
|
<span class="btn-text">START CHALLENGE</span>
|
||||||
|
</button>
|
||||||
|
<div class="controls-hint">
|
||||||
|
<span>⌨️ Mouse Click or Number Keys (1-8) to equip • Spacebar to Test Task</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN 2: ACTIVE GAMEPLAY VIEW -->
|
||||||
|
<section id="screen-game" class="screen-view">
|
||||||
|
<div class="gameplay-grid">
|
||||||
|
|
||||||
|
<!-- Left Column: Hazard Assessment & Task Briefing -->
|
||||||
|
<aside class="sidebar-hazard-panel card-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="scenario-badge" id="scene-badge">SCENARIO 1 OF 2</div>
|
||||||
|
<h2 class="scenario-title" id="scene-title">Metal Grinding Operation</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="situation-card">
|
||||||
|
<div class="card-subtitle">WORKPLACE ENVIRONMENT</div>
|
||||||
|
<p class="situation-desc" id="scene-desc">
|
||||||
|
An industrial worker is preparing to grind weld seams on structural steel with a high-speed angle grinder (11,000 RPM).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hazard-identification-card">
|
||||||
|
<div class="card-subtitle">IDENTIFIED TASK HAZARDS</div>
|
||||||
|
<ul class="hazard-list" id="hazard-list">
|
||||||
|
<!-- Dynamically populated -->
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="equipped-status-card">
|
||||||
|
<div class="card-subtitle">PROTECTION COVERAGE</div>
|
||||||
|
<div class="coverage-grid" id="coverage-grid">
|
||||||
|
<div class="coverage-item" data-slot="head">
|
||||||
|
<span class="cov-icon">🧢</span>
|
||||||
|
<span class="cov-label">Head</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-item" data-slot="eyes">
|
||||||
|
<span class="cov-icon">👓</span>
|
||||||
|
<span class="cov-label">Eyes/Face</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-item" data-slot="ears">
|
||||||
|
<span class="cov-icon">🎧</span>
|
||||||
|
<span class="cov-label">Hearing</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-item" data-slot="hands">
|
||||||
|
<span class="cov-icon">🧤</span>
|
||||||
|
<span class="cov-label">Hands</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-item" data-slot="body">
|
||||||
|
<span class="cov-icon">🦺</span>
|
||||||
|
<span class="cov-label">Body</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
<div class="coverage-item" data-slot="feet">
|
||||||
|
<span class="cov-icon">🥾</span>
|
||||||
|
<span class="cov-label">Feet</span>
|
||||||
|
<span class="cov-status missing">Missing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-dock">
|
||||||
|
<button id="btn-test-safety" class="btn btn-action btn-full glow-ready">
|
||||||
|
<span class="btn-icon">⚡</span>
|
||||||
|
<span id="btn-test-safety-text">TEST WORKER SAFETY & START</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-clear-ppe" class="btn btn-ghost btn-small">
|
||||||
|
<span>↺ Clear All Equipped Items</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Middle Column: Interactive Scene & Worker Avatar -->
|
||||||
|
<div class="center-stage-panel card-panel">
|
||||||
|
<div class="scene-environment-container" id="scene-stage">
|
||||||
|
<!-- Scene canvas for particles/sparks/chemicals -->
|
||||||
|
<canvas id="stage-fx-canvas" class="stage-fx-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- Dynamic Background Scenery Layer -->
|
||||||
|
<div class="scene-backdrop" id="scene-backdrop">
|
||||||
|
<div class="industrial-wall"></div>
|
||||||
|
<div class="workbench">
|
||||||
|
<div class="workpiece-element" id="workpiece-element"></div>
|
||||||
|
<div class="equipment-element" id="equipment-element"></div>
|
||||||
|
</div>
|
||||||
|
<div class="warning-tape"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Worker Avatar Interactive SVG Layer -->
|
||||||
|
<div class="avatar-wrapper" id="avatar-container">
|
||||||
|
<!-- Injected by avatar.js -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Freeze Frame / Critical Warning Alert Overlay -->
|
||||||
|
<div class="consequence-freeze-overlay" id="consequence-overlay">
|
||||||
|
<div class="freeze-banner">
|
||||||
|
<div class="freeze-header">
|
||||||
|
<span class="hazard-flasher" id="hazard-flasher-badge">⚠️ STOP! CRITICAL HAZARDS DETECTED</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="consequence-title" id="consequence-title">Critical Protection Gaps Detected</h3>
|
||||||
|
<p class="consequence-desc" id="consequence-desc">
|
||||||
|
Multiple required protective gear items are missing or improper for this task. Unprotected body zones are exposed to severe workplace trauma.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="missing-items-box">
|
||||||
|
<div class="missing-box-header">
|
||||||
|
<span class="box-label">⚠️ ALL REQUIRED CORRECTIONS (<span id="missing-count-num">0</span> ISSUES):</span>
|
||||||
|
</div>
|
||||||
|
<div class="missing-hazard-cards" id="consequence-hazard-list">
|
||||||
|
<!-- Dynamically rendered list of all missing & incorrect items -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-fix-ppe" class="btn btn-warning glow-effect">
|
||||||
|
<span>🔧 FIX ALL WORKER PPE & RETRY</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success Overlay Banner -->
|
||||||
|
<div class="success-overlay" id="success-overlay">
|
||||||
|
<div class="success-banner">
|
||||||
|
<div class="success-badge">
|
||||||
|
<span class="badge-icon">✅</span>
|
||||||
|
<span class="badge-title" id="success-title">SAFE TO START</span>
|
||||||
|
</div>
|
||||||
|
<p class="success-desc" id="success-desc">
|
||||||
|
Correct PPE selected. Your eyes, face, hands, hearing and feet are fully protected against all task hazards.
|
||||||
|
</p>
|
||||||
|
<div class="points-awarded-pill">
|
||||||
|
<span class="star-icon">⭐</span>
|
||||||
|
<span class="points-text" id="awarded-points-text">+100 Safety Points</span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-next-scenario" class="btn btn-success glow-effect">
|
||||||
|
<span id="btn-next-scenario-text">NEXT SCENARIO ➔</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task Status Indicator -->
|
||||||
|
<div class="stage-footer-bar">
|
||||||
|
<div class="status-indicator-light" id="status-light">
|
||||||
|
<span class="light-dot"></span>
|
||||||
|
<span class="light-text" id="status-text">TASK IDLE - EQUIP PPE AND CLICK TEST</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-feedback-caption" id="audio-caption">
|
||||||
|
[Audio: Workshop Ambience Active]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: PPE Selection Locker / Tool Rack -->
|
||||||
|
<aside class="sidebar-locker-panel card-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3 class="locker-title">🦺 PPE SAFETY LOCKER</h3>
|
||||||
|
<span class="locker-subtitle">Select correct gear for this task:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ppe-locker-grid" id="ppe-locker-grid">
|
||||||
|
<!-- Dynamically populated based on scenario -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="locker-guide-card">
|
||||||
|
<div class="guide-title">💡 Safety Selection Rule</div>
|
||||||
|
<p class="guide-text" id="locker-guide-text">
|
||||||
|
Some gear offers insufficient protection or introduces new hazards. Choose specifically rated items!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SCREEN 3: COMPLETION / FINAL SCORE SCREEN -->
|
||||||
|
<section id="screen-summary" class="screen-view">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-header">
|
||||||
|
<div class="victory-trophy">🏆</div>
|
||||||
|
<h1 class="summary-title">PPE SAFETY CHALLENGE COMPLETE</h1>
|
||||||
|
<p class="summary-subtitle">“Choose Right. Work Safe.”</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics-dashboard">
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-label">FINAL SAFETY SCORE</span>
|
||||||
|
<span class="metric-val" id="summary-score">200 / 200</span>
|
||||||
|
<span class="metric-sub" id="summary-percentage">100% Safety Compliance</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-label">SCENARIOS COMPLETED</span>
|
||||||
|
<span class="metric-val">2 / 2</span>
|
||||||
|
<span class="metric-sub">Zero Workplace Accidents</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-box">
|
||||||
|
<span class="metric-label">SAFETY STARS</span>
|
||||||
|
<span class="metric-val star-gold" id="summary-stars">⭐⭐</span>
|
||||||
|
<span class="metric-sub" id="summary-rank">Master Safety Practitioner</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key Takeaway Mantra -->
|
||||||
|
<div class="takeaway-banner">
|
||||||
|
<div class="takeaway-heading">CORE SAFETY WORKFLOW</div>
|
||||||
|
<div class="mantra-chain">
|
||||||
|
<div class="mantra-step">🛑 <strong>STOP</strong></div>
|
||||||
|
<div class="mantra-arrow">➔</div>
|
||||||
|
<div class="mantra-step">👁️ <strong>LOOK</strong></div>
|
||||||
|
<div class="mantra-arrow">➔</div>
|
||||||
|
<div class="mantra-step">⚠️ <strong>IDENTIFY HAZARDS</strong></div>
|
||||||
|
<div class="mantra-arrow">➔</div>
|
||||||
|
<div class="mantra-step">🦺 <strong>SELECT PPE</strong></div>
|
||||||
|
<div class="mantra-arrow">➔</div>
|
||||||
|
<div class="mantra-step">🛡️ <strong>WORK SAFELY</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scenarios Review Summary Accordion -->
|
||||||
|
<div class="scenarios-review-box">
|
||||||
|
<h4>Safety Assessment Summary</h4>
|
||||||
|
<div class="review-row">
|
||||||
|
<div class="rev-icon">⚡</div>
|
||||||
|
<div class="rev-info">
|
||||||
|
<strong>Scenario 1: Angle Grinding Operation</strong>
|
||||||
|
<p>Protected against flying hot sparks, high decibel noise (95+ dB), and sharp metal burrs using Full Face Shield + Glasses, Leather Grinding Gloves, Hearing Protection & Safety Boots.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rev-status">✅ PASSED</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-row">
|
||||||
|
<div class="rev-icon">🧪</div>
|
||||||
|
<div class="rev-info">
|
||||||
|
<strong>Scenario 2: Industrial Chemical Transfer</strong>
|
||||||
|
<p>Prevented chemical burn & splash injury using Chemical Splash Goggles, Heavy Nitrile Gauntlet Gloves, Chemical Resistant Apron & Rubber Safety Boots.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rev-status">✅ PASSED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-actions">
|
||||||
|
<button id="btn-replay" class="btn btn-primary btn-large">
|
||||||
|
<span>🔄 REPLAY SAFETY CHALLENGE</span>
|
||||||
|
</button>
|
||||||
|
<button id="btn-print-certificate" class="btn btn-secondary btn-large">
|
||||||
|
<span>📜 PRINT SAFETY CERTIFICATE</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Audio Settings Modal Dialog -->
|
||||||
|
<div class="modal-backdrop" id="modal-audio">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>🔊 Audio & Sound Control</h3>
|
||||||
|
<button id="btn-close-audio-modal" class="btn-close" aria-label="Close modal">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="audio-control-row">
|
||||||
|
<label for="toggle-master-mute">Master Audio</label>
|
||||||
|
<input type="checkbox" id="toggle-master-mute" checked>
|
||||||
|
</div>
|
||||||
|
<div class="audio-control-row">
|
||||||
|
<label for="slider-music-vol">Background Music Volume (<span id="music-vol-num">25%</span>)</label>
|
||||||
|
<input type="range" id="slider-music-vol" min="0" max="100" value="25">
|
||||||
|
</div>
|
||||||
|
<div class="audio-control-row">
|
||||||
|
<label for="slider-sfx-vol">Sound Effects (SFX) Volume (<span id="sfx-vol-num">55%</span>)</label>
|
||||||
|
<input type="range" id="slider-sfx-vol" min="0" max="100" value="55">
|
||||||
|
</div>
|
||||||
|
<div class="audio-control-row">
|
||||||
|
<label for="toggle-ambient-hum">Industrial Ambience Hum</label>
|
||||||
|
<input type="checkbox" id="toggle-ambient-hum" checked>
|
||||||
|
</div>
|
||||||
|
<div class="audio-info-note">
|
||||||
|
ℹ️ The game features a real-time Web Audio synthesizer. Audio starts when you click <strong>Start Challenge</strong>. All safety information is also fully visual and accessible when muted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-save-audio" class="btn btn-primary">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Printable Certificate Template (Hidden until printed) -->
|
||||||
|
<div id="printable-certificate" class="printable-certificate">
|
||||||
|
<div class="cert-border">
|
||||||
|
<div class="cert-header">
|
||||||
|
<div class="cert-logo">✚ PPE SAFETY CHALLENGE</div>
|
||||||
|
<h1>CERTIFICATE OF SAFETY EXCELLENCE</h1>
|
||||||
|
<p class="cert-sub">This certifies that</p>
|
||||||
|
<h2 class="cert-name">QUALIFIED INDUSTRIAL EMPLOYEE</h2>
|
||||||
|
<p class="cert-desc">has successfully demonstrated 100% competency in Hazard Identification & Personal Protective Equipment (PPE) Selection across all high-risk industrial scenarios.</p>
|
||||||
|
</div>
|
||||||
|
<div class="cert-footer">
|
||||||
|
<div class="cert-signature">
|
||||||
|
<div class="sig-line"></div>
|
||||||
|
<span>Safety & Environmental Health Officer</span>
|
||||||
|
</div>
|
||||||
|
<div class="cert-seal">
|
||||||
|
⭐ VERIFIED SAFE ⭐
|
||||||
|
</div>
|
||||||
|
<div class="cert-date">
|
||||||
|
<div class="sig-line" id="cert-date-val"></div>
|
||||||
|
<span>Completion Date</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="js/audio.js"></script>
|
||||||
|
<script src="js/scenarios.js"></script>
|
||||||
|
<script src="js/particles.js"></script>
|
||||||
|
<script src="js/avatar.js"></script>
|
||||||
|
<script src="js/game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+744
@@ -0,0 +1,744 @@
|
|||||||
|
/**
|
||||||
|
* PPE SAFETY CHALLENGE - WEB AUDIO SYNTHESIZER & SOUND ENGINE
|
||||||
|
* Professional corporate-industrial sound design, procedural synthesis, and adaptive ducking.
|
||||||
|
* Fully self-contained with no external media dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SoundEngine {
|
||||||
|
constructor() {
|
||||||
|
this.ctx = null;
|
||||||
|
this.masterGain = null;
|
||||||
|
this.musicGain = null;
|
||||||
|
this.sfxGain = null;
|
||||||
|
this.ambientGain = null;
|
||||||
|
|
||||||
|
this.isMuted = false;
|
||||||
|
this.musicVolume = 0.25; // 25% default as requested
|
||||||
|
this.sfxVolume = 0.55; // 55% default as requested
|
||||||
|
this.isAmbientEnabled = true;
|
||||||
|
|
||||||
|
this.isMusicPlaying = false;
|
||||||
|
this.musicInterval = null;
|
||||||
|
this.currentMusicChord = 0;
|
||||||
|
|
||||||
|
this.activeAmbientNodes = [];
|
||||||
|
this.activeGrinderNode = null;
|
||||||
|
this.activeLiquidNode = null;
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the Web Audio context on user gesture.
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
|
this.ctx = new AudioContext();
|
||||||
|
|
||||||
|
// Master Gain
|
||||||
|
this.masterGain = this.ctx.createGain();
|
||||||
|
this.masterGain.gain.setValueAtTime(1.0, this.ctx.currentTime);
|
||||||
|
this.masterGain.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
// Music Channel
|
||||||
|
this.musicGain = this.ctx.createGain();
|
||||||
|
this.musicGain.gain.setValueAtTime(this.musicVolume, this.ctx.currentTime);
|
||||||
|
this.musicGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
// SFX Channel
|
||||||
|
this.sfxGain = this.ctx.createGain();
|
||||||
|
this.sfxGain.gain.setValueAtTime(this.sfxVolume, this.ctx.currentTime);
|
||||||
|
this.sfxGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
// Ambient Channel
|
||||||
|
this.ambientGain = this.ctx.createGain();
|
||||||
|
this.ambientGain.gain.setValueAtTime(0.18, this.ctx.currentTime);
|
||||||
|
this.ambientGain.connect(this.masterGain);
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Web Audio API not supported or blocked:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureContext() {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
if (this.ctx && this.ctx.state === 'suspended') {
|
||||||
|
this.ctx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// VOLUME & MUTE CONTROLS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
setMasterMute(muted) {
|
||||||
|
this.isMuted = muted;
|
||||||
|
if (this.masterGain && this.ctx) {
|
||||||
|
this.masterGain.gain.setTargetAtTime(muted ? 0 : 1.0, this.ctx.currentTime, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMusicVolume(val) {
|
||||||
|
this.musicVolume = Math.max(0, Math.min(1, val));
|
||||||
|
if (this.musicGain && this.ctx) {
|
||||||
|
this.musicGain.gain.setTargetAtTime(this.musicVolume, this.ctx.currentTime, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSfxVolume(val) {
|
||||||
|
this.sfxVolume = Math.max(0, Math.min(1, val));
|
||||||
|
if (this.sfxGain && this.ctx) {
|
||||||
|
this.sfxGain.gain.setTargetAtTime(this.sfxVolume, this.ctx.currentTime, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmbientEnabled(enabled) {
|
||||||
|
this.isAmbientEnabled = enabled;
|
||||||
|
if (this.ambientGain && this.ctx) {
|
||||||
|
this.ambientGain.gain.setTargetAtTime(enabled ? 0.18 : 0, this.ctx.currentTime, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duck music volume during critical warnings / consequences
|
||||||
|
*/
|
||||||
|
duckMusic(ducked = true) {
|
||||||
|
if (!this.musicGain || !this.ctx) return;
|
||||||
|
const target = ducked ? (this.musicVolume * 0.15) : this.musicVolume;
|
||||||
|
this.musicGain.gain.setTargetAtTime(target, this.ctx.currentTime, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// BACKGROUND MUSIC (Corporate-Industrial Synth Loop)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
playStartIntroSting() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// Confident corporate chords: F -> C -> Dm -> Bb
|
||||||
|
const chords = [
|
||||||
|
[349.23, 440.00, 523.25], // F major
|
||||||
|
[261.63, 329.63, 392.00], // C major
|
||||||
|
[293.66, 349.23, 440.00], // D minor
|
||||||
|
[466.16, 587.33, 698.46] // Bb major (high shimmer)
|
||||||
|
];
|
||||||
|
|
||||||
|
chords.forEach((chord, i) => {
|
||||||
|
const time = t + (i * 0.28);
|
||||||
|
chord.forEach(freq => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(freq, time);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(2200, time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.001, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.12, time + 0.05);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.6);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.musicGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + 0.65);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startBackgroundMusic() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (this.isMusicPlaying || !this.ctx) return;
|
||||||
|
this.isMusicPlaying = true;
|
||||||
|
|
||||||
|
// Corporate modern progression: Dm -> Bb -> F -> C
|
||||||
|
const progression = [
|
||||||
|
{ bass: 146.83, chord: [293.66, 349.23, 440.00] }, // Dm
|
||||||
|
{ bass: 116.54, chord: [233.08, 293.66, 349.23] }, // Bb
|
||||||
|
{ bass: 174.61, chord: [261.63, 349.23, 440.00] }, // F
|
||||||
|
{ bass: 130.81, chord: [261.63, 329.63, 392.00] } // C
|
||||||
|
];
|
||||||
|
|
||||||
|
let step = 0;
|
||||||
|
this.musicInterval = setInterval(() => {
|
||||||
|
if (!this.isMusicPlaying || !this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const current = progression[step % progression.length];
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// 1. Warm Bass Pulse
|
||||||
|
const bassOsc = this.ctx.createOscillator();
|
||||||
|
const bassGain = this.ctx.createGain();
|
||||||
|
const bassFilter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
bassOsc.type = 'sawtooth';
|
||||||
|
bassOsc.frequency.setValueAtTime(current.bass, now);
|
||||||
|
|
||||||
|
bassFilter.type = 'lowpass';
|
||||||
|
bassFilter.frequency.setValueAtTime(350, now);
|
||||||
|
|
||||||
|
bassGain.gain.setValueAtTime(0.001, now);
|
||||||
|
bassGain.gain.linearRampToValueAtTime(0.08, now + 0.08);
|
||||||
|
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 1.8);
|
||||||
|
|
||||||
|
bassOsc.connect(bassFilter);
|
||||||
|
bassFilter.connect(bassGain);
|
||||||
|
bassGain.connect(this.musicGain);
|
||||||
|
|
||||||
|
bassOsc.start(now);
|
||||||
|
bassOsc.stop(now + 1.9);
|
||||||
|
|
||||||
|
// 2. Soft Pad Chords (Calm Corporate Ambiance)
|
||||||
|
current.chord.forEach((freq, idx) => {
|
||||||
|
const padOsc = this.ctx.createOscillator();
|
||||||
|
const padGain = this.ctx.createGain();
|
||||||
|
const padFilter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
padOsc.type = 'sine';
|
||||||
|
padOsc.frequency.setValueAtTime(freq, now);
|
||||||
|
|
||||||
|
padFilter.type = 'lowpass';
|
||||||
|
padFilter.frequency.setValueAtTime(800 + (idx * 200), now);
|
||||||
|
|
||||||
|
padGain.gain.setValueAtTime(0.001, now);
|
||||||
|
padGain.gain.linearRampToValueAtTime(0.04, now + 0.3);
|
||||||
|
padGain.gain.exponentialRampToValueAtTime(0.001, now + 1.9);
|
||||||
|
|
||||||
|
padOsc.connect(padFilter);
|
||||||
|
padFilter.connect(padGain);
|
||||||
|
padGain.connect(this.musicGain);
|
||||||
|
|
||||||
|
padOsc.start(now);
|
||||||
|
padOsc.stop(now + 2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Subtle Energetic Arpeggio Tick
|
||||||
|
[0, 0.45, 0.9, 1.35].forEach((offset, i) => {
|
||||||
|
const arpTime = now + offset;
|
||||||
|
const arpOsc = this.ctx.createOscillator();
|
||||||
|
const arpGain = this.ctx.createGain();
|
||||||
|
|
||||||
|
arpOsc.type = 'sine';
|
||||||
|
const arpFreq = current.chord[i % current.chord.length] * 2;
|
||||||
|
arpOsc.frequency.setValueAtTime(arpFreq, arpTime);
|
||||||
|
|
||||||
|
arpGain.gain.setValueAtTime(0.001, arpTime);
|
||||||
|
arpGain.gain.linearRampToValueAtTime(0.015, arpTime + 0.02);
|
||||||
|
arpGain.gain.exponentialRampToValueAtTime(0.0001, arpTime + 0.2);
|
||||||
|
|
||||||
|
arpOsc.connect(arpGain);
|
||||||
|
arpGain.connect(this.musicGain);
|
||||||
|
|
||||||
|
arpOsc.start(arpTime);
|
||||||
|
arpOsc.stop(arpTime + 0.22);
|
||||||
|
});
|
||||||
|
|
||||||
|
step++;
|
||||||
|
}, 1800);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBackgroundMusic() {
|
||||||
|
this.isMusicPlaying = false;
|
||||||
|
if (this.musicInterval) {
|
||||||
|
clearInterval(this.musicInterval);
|
||||||
|
this.musicInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AMBIENT SOUNDS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
setAmbientScenario(scenarioId) {
|
||||||
|
this.ensureContext();
|
||||||
|
this.stopAmbient();
|
||||||
|
if (!this.ctx || !this.isAmbientEnabled) return;
|
||||||
|
|
||||||
|
if (scenarioId === 'grinding') {
|
||||||
|
// Workshop machinery hum & distant resonance
|
||||||
|
const osc1 = this.ctx.createOscillator();
|
||||||
|
const osc2 = this.ctx.createOscillator();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc1.type = 'sine';
|
||||||
|
osc1.frequency.setValueAtTime(65, this.ctx.currentTime); // 60Hz grid hum
|
||||||
|
|
||||||
|
osc2.type = 'triangle';
|
||||||
|
osc2.frequency.setValueAtTime(120, this.ctx.currentTime);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(250, this.ctx.currentTime);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.06, this.ctx.currentTime);
|
||||||
|
|
||||||
|
osc1.connect(filter);
|
||||||
|
osc2.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.ambientGain);
|
||||||
|
|
||||||
|
osc1.start();
|
||||||
|
osc2.start();
|
||||||
|
|
||||||
|
this.activeAmbientNodes = [osc1, osc2, gain];
|
||||||
|
} else if (scenarioId === 'chemical') {
|
||||||
|
// Chemical fume hood ventilation air flow (filtered white noise)
|
||||||
|
const bufferSize = this.ctx.sampleRate * 2;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noiseSource = this.ctx.createBufferSource();
|
||||||
|
noiseSource.buffer = buffer;
|
||||||
|
noiseSource.loop = true;
|
||||||
|
|
||||||
|
const bandpass = this.ctx.createBiquadFilter();
|
||||||
|
bandpass.type = 'bandpass';
|
||||||
|
bandpass.frequency.setValueAtTime(450, this.ctx.currentTime);
|
||||||
|
bandpass.Q.setValueAtTime(1.5, this.ctx.currentTime);
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
|
||||||
|
|
||||||
|
noiseSource.connect(bandpass);
|
||||||
|
bandpass.connect(gain);
|
||||||
|
gain.connect(this.ambientGain);
|
||||||
|
|
||||||
|
noiseSource.start();
|
||||||
|
this.activeAmbientNodes = [noiseSource, gain];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAmbient() {
|
||||||
|
this.activeAmbientNodes.forEach(node => {
|
||||||
|
try {
|
||||||
|
if (node.stop) node.stop();
|
||||||
|
if (node.disconnect) node.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
this.activeAmbientNodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// UI INTERACTION SOUND EFFECTS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
playEquipSFX() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Crisp mechanical gear latch sound
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.setValueAtTime(320, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(120, t + 0.06);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(1200, t);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.12, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
playDeselectSFX() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(180, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(260, t + 0.05);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.08, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
playButtonClick() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(600, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(200, t + 0.03);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.1, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
playSuccessChime() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Harmonic positive triad: C5 (523.25) -> E5 (659.25) -> G5 (783.99) -> C6 (1046.50)
|
||||||
|
const notes = [523.25, 659.25, 783.99, 1046.50];
|
||||||
|
notes.forEach((freq, i) => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, t + (i * 0.09));
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.001, t + (i * 0.09));
|
||||||
|
gain.gain.linearRampToValueAtTime(0.16, t + (i * 0.09) + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + (i * 0.09) + 0.5);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t + (i * 0.09));
|
||||||
|
osc.stop(t + (i * 0.09) + 0.55);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playSafetyStarSound() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Shimmer sparkle sweep
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const time = t + (i * 0.06);
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(900 + (i * 240), time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.09, time);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + 0.25);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + 0.26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playWarningTone() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Measured industrial safety double-beep (calibrated, not jarring)
|
||||||
|
[0, 0.16].forEach(offset => {
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(480, t + offset);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.14, t + offset);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.1);
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(1000, t + offset);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t + offset);
|
||||||
|
osc.stop(t + offset + 0.12);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
playCriticalStopAlert() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Firm low "STOP" tone pulse
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(220, t);
|
||||||
|
osc.frequency.linearRampToValueAtTime(180, t + 0.25);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.2, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
playWhooshImpact() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Quick swoosh particle sound for near-miss freeze frame
|
||||||
|
const bufferSize = this.ctx.sampleRate * 0.3;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.setValueAtTime(600, t);
|
||||||
|
filter.frequency.exponentialRampToValueAtTime(2200, t + 0.18);
|
||||||
|
filter.Q.setValueAtTime(3.0, t);
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.01, t);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.18, t + 0.15);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
|
||||||
|
|
||||||
|
source.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
source.start(t);
|
||||||
|
source.stop(t + 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TASK SPECIFIC AUDIO (Grinder & Chemical)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
startGrinderSFX() {
|
||||||
|
this.ensureContext();
|
||||||
|
this.stopGrinderSFX();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Grinder motor spin-up (11000 RPM whine) + spark friction sizzle
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(160, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(420, t + 0.5);
|
||||||
|
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.setValueAtTime(1400, t);
|
||||||
|
filter.Q.setValueAtTime(2.0, t);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.01, t);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||||
|
|
||||||
|
// Spark friction noise
|
||||||
|
const bufferSize = this.ctx.sampleRate * 2;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * 0.3;
|
||||||
|
}
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
noise.loop = true;
|
||||||
|
|
||||||
|
const noiseFilter = this.ctx.createBiquadFilter();
|
||||||
|
noiseFilter.type = 'highpass';
|
||||||
|
noiseFilter.frequency.setValueAtTime(3000, t);
|
||||||
|
|
||||||
|
const noiseGain = this.ctx.createGain();
|
||||||
|
noiseGain.gain.setValueAtTime(0.01, t);
|
||||||
|
noiseGain.gain.linearRampToValueAtTime(0.08, t + 0.3);
|
||||||
|
|
||||||
|
noise.connect(noiseFilter);
|
||||||
|
noiseFilter.connect(noiseGain);
|
||||||
|
noiseGain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
noise.start(t);
|
||||||
|
|
||||||
|
this.activeGrinderNode = { osc, gain, noise, noiseGain };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopGrinderSFX() {
|
||||||
|
if (this.activeGrinderNode && this.ctx) {
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
try {
|
||||||
|
this.activeGrinderNode.gain.gain.linearRampToValueAtTime(0.001, t + 0.1);
|
||||||
|
this.activeGrinderNode.noiseGain.gain.linearRampToValueAtTime(0.001, t + 0.1);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.activeGrinderNode.osc.stop();
|
||||||
|
this.activeGrinderNode.noise.stop();
|
||||||
|
} catch (e) {}
|
||||||
|
this.activeGrinderNode = null;
|
||||||
|
}, 120);
|
||||||
|
} catch (e) {
|
||||||
|
this.activeGrinderNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startLiquidTransferSFX() {
|
||||||
|
this.ensureContext();
|
||||||
|
this.stopLiquidTransferSFX();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Gentle liquid pour / laminar stream sound
|
||||||
|
const bufferSize = this.ctx.sampleRate * 2;
|
||||||
|
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < bufferSize; i++) {
|
||||||
|
data[i] = (Math.random() * 2 - 1) * 0.2;
|
||||||
|
}
|
||||||
|
const noise = this.ctx.createBufferSource();
|
||||||
|
noise.buffer = buffer;
|
||||||
|
noise.loop = true;
|
||||||
|
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.setValueAtTime(850, t);
|
||||||
|
filter.Q.setValueAtTime(3.5, t);
|
||||||
|
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0.01, t);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.12, t + 0.2);
|
||||||
|
|
||||||
|
noise.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
noise.start(t);
|
||||||
|
this.activeLiquidNode = { noise, gain };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLiquidTransferSFX() {
|
||||||
|
if (this.activeLiquidNode && this.ctx) {
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
try {
|
||||||
|
this.activeLiquidNode.gain.gain.linearRampToValueAtTime(0.001, t + 0.1);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.activeLiquidNode.noise.stop();
|
||||||
|
} catch (e) {}
|
||||||
|
this.activeLiquidNode = null;
|
||||||
|
}, 120);
|
||||||
|
} catch (e) {
|
||||||
|
this.activeLiquidNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playChemicalSplashSFX() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.setValueAtTime(350, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(140, t + 0.12);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.18, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
|
||||||
|
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(this.sfxGain);
|
||||||
|
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
playVictorySting() {
|
||||||
|
this.ensureContext();
|
||||||
|
if (!this.ctx || this.isMuted) return;
|
||||||
|
|
||||||
|
this.stopBackgroundMusic();
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
|
||||||
|
// Professional triumphant fanfare: C4 -> E4 -> G4 -> C5 (sustained)
|
||||||
|
const melody = [
|
||||||
|
{ freq: 523.25, duration: 0.18, delay: 0 },
|
||||||
|
{ freq: 659.25, duration: 0.18, delay: 0.18 },
|
||||||
|
{ freq: 783.99, duration: 0.22, delay: 0.36 },
|
||||||
|
{ freq: 1046.50, duration: 1.2, delay: 0.58 }
|
||||||
|
];
|
||||||
|
|
||||||
|
melody.forEach(note => {
|
||||||
|
const time = t + note.delay;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
const filter = this.ctx.createBiquadFilter();
|
||||||
|
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(note.freq, time);
|
||||||
|
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.setValueAtTime(2400, time);
|
||||||
|
|
||||||
|
gain.gain.setValueAtTime(0.001, time);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.18, time + 0.04);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, time + note.duration);
|
||||||
|
|
||||||
|
osc.connect(filter);
|
||||||
|
filter.connect(gain);
|
||||||
|
gain.connect(this.musicGain);
|
||||||
|
|
||||||
|
osc.start(time);
|
||||||
|
osc.stop(time + note.duration + 0.05);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Sound Engine Instance
|
||||||
|
window.soundEngine = new SoundEngine();
|
||||||
+336
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* PPE SAFETY CHALLENGE - DYNAMIC WORKER AVATAR SYSTEM
|
||||||
|
* Generates an SVG industrial worker avatar with modular layered PPE gear.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class WorkerAvatar {
|
||||||
|
constructor(containerId) {
|
||||||
|
this.container = document.getElementById(containerId);
|
||||||
|
this.equippedItems = new Set();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setEquipped(itemIds) {
|
||||||
|
this.equippedItems = new Set(itemIds);
|
||||||
|
this.updateLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
equip(itemId) {
|
||||||
|
this.equippedItems.add(itemId);
|
||||||
|
this.updateLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
unequip(itemId) {
|
||||||
|
this.equippedItems.delete(itemId);
|
||||||
|
this.updateLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
has(itemId) {
|
||||||
|
return this.equippedItems.has(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvatarCenterCoordinates() {
|
||||||
|
if (!this.container) return { x: 150, y: 200, eyeX: 140, eyeY: 100, handX: 190, handY: 220 };
|
||||||
|
const rect = this.container.getBoundingClientRect();
|
||||||
|
const parentRect = this.container.parentElement.getBoundingClientRect();
|
||||||
|
const relX = rect.left - parentRect.left + (rect.width * 0.5);
|
||||||
|
const relY = rect.top - parentRect.top + (rect.height * 0.5);
|
||||||
|
return {
|
||||||
|
x: relX,
|
||||||
|
y: relY,
|
||||||
|
eyeX: relX - 10,
|
||||||
|
eyeY: relY - 110,
|
||||||
|
handX: relX + 45,
|
||||||
|
handY: relY + 15
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<svg viewBox="0 0 300 450" class="worker-svg" id="worker-avatar-svg" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="skinGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f6d5b8"/>
|
||||||
|
<stop offset="100%" stop-color="#e2b792"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="hairGrad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#3b291d"/>
|
||||||
|
<stop offset="100%" stop-color="#24180f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="baseSuitGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2c3e50"/>
|
||||||
|
<stop offset="100%" stop-color="#1a252f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leatherGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#a0522d"/>
|
||||||
|
<stop offset="100%" stop-color="#723616"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nitrileGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0284c7"/>
|
||||||
|
<stop offset="100%" stop-color="#0369a1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="visorGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="rgba(14, 165, 233, 0.4)"/>
|
||||||
|
<stop offset="100%" stop-color="rgba(2, 132, 199, 0.6)"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="chemicalApronGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#059669"/>
|
||||||
|
<stop offset="100%" stop-color="#047857"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="gearGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#facc15" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Shadow on floor -->
|
||||||
|
<ellipse cx="150" cy="425" rx="75" ry="12" fill="rgba(0,0,0,0.4)"/>
|
||||||
|
|
||||||
|
<!-- LAYER: BASE WORKER BODY -->
|
||||||
|
<g id="layer-base-body">
|
||||||
|
<!-- Legs / Basic Work Trousers -->
|
||||||
|
<path d="M 120 230 L 110 375 L 138 375 L 146 250 L 154 250 L 162 375 L 190 375 L 180 230 Z" fill="#1e293b"/>
|
||||||
|
|
||||||
|
<!-- Feet (Base work shoes) -->
|
||||||
|
<g id="layer-feet-base">
|
||||||
|
<path d="M 105 375 L 140 375 L 142 395 L 98 395 Z" fill="#334155"/>
|
||||||
|
<path d="M 160 375 L 195 375 L 202 395 L 158 395 Z" fill="#334155"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Torso (Base blue industrial shirt) -->
|
||||||
|
<path d="M 110 140 C 110 130 190 130 190 140 L 196 235 C 196 240 104 240 104 235 Z" fill="url(#baseSuitGrad)"/>
|
||||||
|
|
||||||
|
<!-- Arms (Base) -->
|
||||||
|
<path d="M 110 140 L 80 215 L 98 222 L 122 160 Z" fill="url(#baseSuitGrad)"/>
|
||||||
|
<path d="M 190 140 L 220 215 L 202 222 L 178 160 Z" fill="url(#baseSuitGrad)"/>
|
||||||
|
|
||||||
|
<!-- Forearms (Skin) -->
|
||||||
|
<path d="M 80 215 L 70 260 L 84 262 L 98 222 Z" fill="url(#skinGrad)"/>
|
||||||
|
<path d="M 220 215 L 230 260 L 216 262 L 202 222 Z" fill="url(#skinGrad)"/>
|
||||||
|
|
||||||
|
<!-- Hands (Skin - Bare) -->
|
||||||
|
<g id="layer-hands-bare">
|
||||||
|
<ellipse cx="74" cy="272" rx="10" ry="14" fill="url(#skinGrad)"/>
|
||||||
|
<ellipse cx="226" cy="272" rx="10" ry="14" fill="url(#skinGrad)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Neck -->
|
||||||
|
<rect x="138" y="115" width="24" height="25" rx="4" fill="url(#skinGrad)"/>
|
||||||
|
|
||||||
|
<!-- Head -->
|
||||||
|
<ellipse cx="150" cy="95" rx="28" ry="34" fill="url(#skinGrad)"/>
|
||||||
|
|
||||||
|
<!-- Hair Base -->
|
||||||
|
<path d="M 122 90 C 122 65 178 65 178 90 C 178 72 165 62 150 62 C 135 62 122 72 122 90 Z" fill="url(#hairGrad)"/>
|
||||||
|
|
||||||
|
<!-- Eyes & Eyebrows -->
|
||||||
|
<ellipse cx="140" cy="94" rx="3.5" ry="3" fill="#1e293b"/>
|
||||||
|
<ellipse cx="160" cy="94" rx="3.5" ry="3" fill="#1e293b"/>
|
||||||
|
<path d="M 134 88 Q 140 85 146 88" stroke="#3b291d" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M 154 88 Q 160 85 166 88" stroke="#3b291d" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Nose & Mouth -->
|
||||||
|
<path d="M 150 96 L 147 106 L 153 106" stroke="#c08c65" stroke-width="1.5" fill="none"/>
|
||||||
|
<path d="M 143 116 Q 150 120 157 116" stroke="#b45309" stroke-width="2" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Ears -->
|
||||||
|
<ellipse cx="121" cy="96" rx="4" ry="7" fill="url(#skinGrad)"/>
|
||||||
|
<ellipse cx="179" cy="96" rx="4" ry="7" fill="url(#skinGrad)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ====================================================
|
||||||
|
EQUIPMENT LAYERS (Toggled by class or visibility)
|
||||||
|
==================================================== -->
|
||||||
|
|
||||||
|
<!-- 1. HEAD GEAR -->
|
||||||
|
<!-- Hard Hat (Grinding / General) -->
|
||||||
|
<g id="layer-gear-hard_hat_cap" style="display: none;">
|
||||||
|
<path d="M 115 82 C 115 48 185 48 185 82 L 195 86 C 195 90 105 90 105 86 Z" fill="#facc15" stroke="#ca8a04" stroke-width="2"/>
|
||||||
|
<path d="M 144 48 L 156 48 L 154 85 L 146 85 Z" fill="#eab308"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hard Hat (Chemical) -->
|
||||||
|
<g id="layer-gear-hard_hat_chemical" style="display: none;">
|
||||||
|
<path d="M 115 82 C 115 48 185 48 185 82 L 195 86 C 195 90 105 90 105 86 Z" fill="#f8fafc" stroke="#94a3b8" stroke-width="2"/>
|
||||||
|
<path d="M 144 48 L 156 48 L 154 85 L 146 85 Z" fill="#cbd5e1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 2. HEARING / RESPIRATORY GEAR -->
|
||||||
|
<!-- Heavy Ear Muffs -->
|
||||||
|
<g id="layer-gear-earmuffs_heavy" style="display: none;">
|
||||||
|
<path d="M 116 80 C 116 45 184 45 184 80" stroke="#dc2626" stroke-width="5" fill="none"/>
|
||||||
|
<rect x="114" y="86" width="10" height="22" rx="4" fill="#ef4444" stroke="#991b1b" stroke-width="1.5"/>
|
||||||
|
<rect x="176" y="86" width="10" height="22" rx="4" fill="#ef4444" stroke="#991b1b" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Consumer Earbuds (Distractor) -->
|
||||||
|
<g id="layer-gear-wireless_earbuds" style="display: none;">
|
||||||
|
<ellipse cx="122" cy="98" rx="2" ry="3" fill="#ffffff"/>
|
||||||
|
<ellipse cx="178" cy="98" rx="2" ry="3" fill="#ffffff"/>
|
||||||
|
<line x1="122" y1="98" x2="122" y2="108" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
<line x1="178" y1="98" x2="178" y2="108" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Organic Vapor Half-Mask Respirator -->
|
||||||
|
<g id="layer-gear-organic_vapor_respirator" style="display: none;">
|
||||||
|
<path d="M 136 102 L 164 102 L 160 126 L 140 126 Z" fill="#475569" stroke="#1e293b" stroke-width="1.5"/>
|
||||||
|
<!-- Cartridge canisters on sides -->
|
||||||
|
<ellipse cx="132" cy="115" rx="7" ry="9" fill="#0284c7" stroke="#0369a1" stroke-width="1.5"/>
|
||||||
|
<ellipse cx="168" cy="115" rx="7" ry="9" fill="#0284c7" stroke="#0369a1" stroke-width="1.5"/>
|
||||||
|
<!-- Straps -->
|
||||||
|
<line x1="136" y1="104" x2="122" y2="92" stroke="#334155" stroke-width="2"/>
|
||||||
|
<line x1="164" y1="104" x2="178" y2="92" stroke="#334155" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Cloth Dust Mask (Distractor) -->
|
||||||
|
<g id="layer-gear-cloth_dust_mask" style="display: none;">
|
||||||
|
<path d="M 134 102 C 134 102 150 100 166 102 L 160 125 C 150 128 150 128 140 125 Z" fill="#93c5fd" stroke="#60a5fa" stroke-width="1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 3. EYE & FACE GEAR -->
|
||||||
|
<!-- Face Shield + Safety Glasses (Grinding) -->
|
||||||
|
<g id="layer-gear-faceshield_glasses" style="display: none;">
|
||||||
|
<!-- Dual Glasses underneath -->
|
||||||
|
<rect x="130" y="88" width="18" height="12" rx="3" fill="rgba(6, 182, 212, 0.3)" stroke="#0891b2" stroke-width="2"/>
|
||||||
|
<rect x="152" y="88" width="18" height="12" rx="3" fill="rgba(6, 182, 212, 0.3)" stroke="#0891b2" stroke-width="2"/>
|
||||||
|
<line x1="148" y1="92" x2="152" y2="92" stroke="#0891b2" stroke-width="2"/>
|
||||||
|
<!-- Full Clear Curved Visor Overlay -->
|
||||||
|
<path d="M 112 78 L 188 78 L 182 135 C 182 142 118 142 118 135 Z" fill="url(#visorGrad)" stroke="#38bdf8" stroke-width="2" opacity="0.85"/>
|
||||||
|
<!-- Brow guard -->
|
||||||
|
<path d="M 112 76 L 188 76 L 186 82 L 114 82 Z" fill="#0369a1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Chemical Splash Goggles -->
|
||||||
|
<g id="layer-gear-chemical_splash_goggles" style="display: none;">
|
||||||
|
<path d="M 124 86 C 124 86 150 84 176 86 L 174 104 C 150 106 150 106 126 104 Z" fill="rgba(16, 185, 129, 0.35)" stroke="#059669" stroke-width="2.5"/>
|
||||||
|
<ellipse cx="138" cy="94" rx="10" ry="7" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||||
|
<ellipse cx="162" cy="94" rx="10" ry="7" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||||
|
<line x1="124" y1="94" x2="120" y2="94" stroke="#065f46" stroke-width="3"/>
|
||||||
|
<line x1="176" y1="94" x2="180" y2="94" stroke="#065f46" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Standard Sunglasses (Distractor) -->
|
||||||
|
<g id="layer-gear-regular_sunglasses" style="display: none;">
|
||||||
|
<rect x="130" y="90" width="18" height="10" rx="2" fill="#111827" stroke="#000" stroke-width="2"/>
|
||||||
|
<rect x="152" y="90" width="18" height="10" rx="2" fill="#111827" stroke="#000" stroke-width="2"/>
|
||||||
|
<line x1="148" y1="93" x2="152" y2="93" stroke="#000" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Open Safety Glasses (Distractor in Chemical) -->
|
||||||
|
<g id="layer-gear-standard_safety_glasses" style="display: none;">
|
||||||
|
<rect x="130" y="90" width="18" height="10" rx="2" fill="rgba(255,255,255,0.4)" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
<rect x="152" y="90" width="18" height="10" rx="2" fill="rgba(255,255,255,0.4)" stroke="#64748b" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 4. BODY PROTECTION -->
|
||||||
|
<!-- Heavy Leather Grinding Jacket -->
|
||||||
|
<g id="layer-gear-heavy_leather_jacket" style="display: none;">
|
||||||
|
<path d="M 104 136 C 104 126 196 126 196 136 L 202 240 L 98 240 Z" fill="url(#leatherGrad)" stroke="#57260d" stroke-width="2"/>
|
||||||
|
<!-- Leather Sleeves -->
|
||||||
|
<path d="M 106 138 L 72 248 L 94 252 L 122 160 Z" fill="url(#leatherGrad)"/>
|
||||||
|
<path d="M 194 138 L 228 248 L 206 252 L 178 160 Z" fill="url(#leatherGrad)"/>
|
||||||
|
<line x1="150" y1="140" x2="150" y2="240" stroke="#451a03" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Chemical-Resistant Apron -->
|
||||||
|
<g id="layer-gear-chemical_resistant_apron" style="display: none;">
|
||||||
|
<path d="M 126 135 L 174 135 L 188 175 L 192 315 L 108 315 L 112 175 Z" fill="url(#chemicalApronGrad)" stroke="#064e3b" stroke-width="2.5"/>
|
||||||
|
<path d="M 130 135 L 138 115 M 170 135 L 162 115" stroke="#047857" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Cotton Lab Coat (Distractor) -->
|
||||||
|
<g id="layer-gear-cotton_lab_coat" style="display: none;">
|
||||||
|
<path d="M 104 136 L 196 136 L 200 300 L 100 300 Z" fill="#f1f5f9" stroke="#cbd5e1" stroke-width="2"/>
|
||||||
|
<line x1="150" y1="136" x2="150" y2="300" stroke="#94a3b8" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 5. HAND PROTECTION -->
|
||||||
|
<!-- Heavy Leather Grinding Gloves -->
|
||||||
|
<g id="layer-gear-leather_grinding_gloves" style="display: none;">
|
||||||
|
<path d="M 64 250 L 86 252 L 86 288 L 62 284 Z" fill="url(#leatherGrad)" stroke="#57260d" stroke-width="1.5"/>
|
||||||
|
<path d="M 236 250 L 214 252 L 214 288 L 238 284 Z" fill="url(#leatherGrad)" stroke="#57260d" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Nitrile Gauntlet Chemical Gloves -->
|
||||||
|
<g id="layer-gear-nitrile_gauntlet_gloves" style="display: none;">
|
||||||
|
<path d="M 62 235 L 88 238 L 86 288 L 60 284 Z" fill="url(#nitrileGrad)" stroke="#0369a1" stroke-width="2"/>
|
||||||
|
<path d="M 238 235 L 212 238 L 214 288 L 240 284 Z" fill="url(#nitrileGrad)" stroke="#0369a1" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Thin Latex Gloves (Distractor) -->
|
||||||
|
<g id="layer-gear-thin_latex_gloves" style="display: none;">
|
||||||
|
<ellipse cx="74" cy="272" rx="11" ry="15" fill="#bae6fd" stroke="#38bdf8" stroke-width="1"/>
|
||||||
|
<ellipse cx="226" cy="272" rx="11" ry="15" fill="#bae6fd" stroke="#38bdf8" stroke-width="1"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Standard Leather Work Gloves (Distractor in Chemical) -->
|
||||||
|
<g id="layer-gear-leather_work_gloves" style="display: none;">
|
||||||
|
<path d="M 64 255 L 86 257 L 86 285 L 62 282 Z" fill="#d97706" stroke="#b45309" stroke-width="1.5"/>
|
||||||
|
<path d="M 236 255 L 214 257 L 214 285 L 238 282 Z" fill="#d97706" stroke="#b45309" stroke-width="1.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 6. FOOT PROTECTION -->
|
||||||
|
<!-- Steel-Toe Safety Boots -->
|
||||||
|
<g id="layer-gear-steel_toe_boots" style="display: none;">
|
||||||
|
<path d="M 100 365 L 142 365 L 146 405 L 94 405 Z" fill="#1c1917" stroke="#44403c" stroke-width="2"/>
|
||||||
|
<path d="M 94 395 L 115 395 L 115 405 L 94 405 Z" fill="#78716c"/> <!-- Steel Cap Plate -->
|
||||||
|
<path d="M 158 365 L 200 365 L 206 405 L 154 405 Z" fill="#1c1917" stroke="#44403c" stroke-width="2"/>
|
||||||
|
<path d="M 185 395 L 206 395 L 206 405 L 185 405 Z" fill="#78716c"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Chemical Rubber Boots -->
|
||||||
|
<g id="layer-gear-chemical_rubber_boots" style="display: none;">
|
||||||
|
<path d="M 98 345 L 144 345 L 146 408 L 92 408 Z" fill="#047857" stroke="#064e3b" stroke-width="2.5"/>
|
||||||
|
<path d="M 156 345 L 202 345 L 208 408 L 154 408 Z" fill="#047857" stroke="#064e3b" stroke-width="2.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Canvas Running Sneakers (Distractor) -->
|
||||||
|
<g id="layer-gear-regular_sneakers" style="display: none;">
|
||||||
|
<path d="M 102 375 L 140 375 L 144 398 L 96 398 Z" fill="#ef4444" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
<path d="M 160 375 L 198 375 L 204 398 L 156 398 Z" fill="#ef4444" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.updateLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLayers() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
// All gear IDs configured across scenarios
|
||||||
|
const allGearIds = [
|
||||||
|
'hard_hat_cap', 'hard_hat_chemical',
|
||||||
|
'faceshield_glasses', 'chemical_splash_goggles', 'regular_sunglasses', 'standard_safety_glasses',
|
||||||
|
'earmuffs_heavy', 'wireless_earbuds', 'organic_vapor_respirator', 'cloth_dust_mask',
|
||||||
|
'heavy_leather_jacket', 'chemical_resistant_apron', 'cotton_lab_coat',
|
||||||
|
'leather_grinding_gloves', 'nitrile_gauntlet_gloves', 'thin_latex_gloves', 'leather_work_gloves',
|
||||||
|
'steel_toe_boots', 'chemical_rubber_boots', 'regular_sneakers'
|
||||||
|
];
|
||||||
|
|
||||||
|
allGearIds.forEach(gearId => {
|
||||||
|
const el = document.getElementById(`layer-gear-${gearId}`);
|
||||||
|
if (el) {
|
||||||
|
el.style.display = this.equippedItems.has(gearId) ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle bare hands layer if gloves equipped
|
||||||
|
const bareHandsEl = document.getElementById('layer-hands-bare');
|
||||||
|
const hasAnyGloves = ['leather_grinding_gloves', 'nitrile_gauntlet_gloves', 'thin_latex_gloves', 'leather_work_gloves']
|
||||||
|
.some(g => this.equippedItems.has(g));
|
||||||
|
if (bareHandsEl) {
|
||||||
|
bareHandsEl.style.display = hasAnyGloves ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle base feet layer if special boots equipped
|
||||||
|
const baseFeetEl = document.getElementById('layer-feet-base');
|
||||||
|
const hasAnyBoots = ['steel_toe_boots', 'chemical_rubber_boots', 'regular_sneakers']
|
||||||
|
.some(b => this.equippedItems.has(b));
|
||||||
|
if (baseFeetEl) {
|
||||||
|
baseFeetEl.style.display = hasAnyBoots ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+629
@@ -0,0 +1,629 @@
|
|||||||
|
/**
|
||||||
|
* PPE SAFETY CHALLENGE - CORE GAME ENGINE & STATE CONTROLLER
|
||||||
|
* "Choose Right. Work Safe."
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SafetyGame {
|
||||||
|
constructor() {
|
||||||
|
this.currentScenarioIndex = 0; // 0 for Grinding, 1 for Chemical
|
||||||
|
this.currentScenario = null;
|
||||||
|
this.score = 0;
|
||||||
|
this.scenarioFirstAttempt = true;
|
||||||
|
this.scenarioCompleted = [false, false];
|
||||||
|
|
||||||
|
this.equippedItems = new Set();
|
||||||
|
this.avatar = null;
|
||||||
|
this.particles = null;
|
||||||
|
|
||||||
|
// Dom Elements Cache
|
||||||
|
this.dom = {
|
||||||
|
screenStart: document.getElementById('screen-start'),
|
||||||
|
screenGame: document.getElementById('screen-game'),
|
||||||
|
screenSummary: document.getElementById('screen-summary'),
|
||||||
|
|
||||||
|
btnStartGame: document.getElementById('btn-start-game'),
|
||||||
|
btnTestSafety: document.getElementById('btn-test-safety'),
|
||||||
|
btnTestSafetyText: document.getElementById('btn-test-safety-text'),
|
||||||
|
btnClearPpe: document.getElementById('btn-clear-ppe'),
|
||||||
|
btnFixPpe: document.getElementById('btn-fix-ppe'),
|
||||||
|
btnNextScenario: document.getElementById('btn-next-scenario'),
|
||||||
|
btnNextScenarioText: document.getElementById('btn-next-scenario-text'),
|
||||||
|
btnReplay: document.getElementById('btn-replay'),
|
||||||
|
btnPrintCertificate: document.getElementById('btn-print-certificate'),
|
||||||
|
|
||||||
|
// HUD
|
||||||
|
hudScore: document.getElementById('hud-score'),
|
||||||
|
hudStars: document.getElementById('hud-stars'),
|
||||||
|
stepPill1: document.getElementById('step-pill-1'),
|
||||||
|
stepPill2: document.getElementById('step-pill-2'),
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
sceneBadge: document.getElementById('scene-badge'),
|
||||||
|
sceneTitle: document.getElementById('scene-title'),
|
||||||
|
sceneDesc: document.getElementById('scene-desc'),
|
||||||
|
hazardList: document.getElementById('hazard-list'),
|
||||||
|
coverageGrid: document.getElementById('coverage-grid'),
|
||||||
|
sceneStage: document.getElementById('scene-stage'),
|
||||||
|
statusLight: document.getElementById('status-light'),
|
||||||
|
statusText: document.getElementById('status-text'),
|
||||||
|
audioCaption: document.getElementById('audio-caption'),
|
||||||
|
|
||||||
|
// Locker
|
||||||
|
lockerGrid: document.getElementById('ppe-locker-grid'),
|
||||||
|
lockerGuideText: document.getElementById('locker-guide-text'),
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
consequenceOverlay: document.getElementById('consequence-overlay'),
|
||||||
|
hazardFlasherBadge: document.getElementById('hazard-flasher-badge'),
|
||||||
|
consequenceTitle: document.getElementById('consequence-title'),
|
||||||
|
consequenceDesc: document.getElementById('consequence-desc'),
|
||||||
|
missingCountNum: document.getElementById('missing-count-num'),
|
||||||
|
consequenceHazardList: document.getElementById('consequence-hazard-list'),
|
||||||
|
|
||||||
|
successOverlay: document.getElementById('success-overlay'),
|
||||||
|
successTitle: document.getElementById('success-title'),
|
||||||
|
successDesc: document.getElementById('success-desc'),
|
||||||
|
awardedPointsText: document.getElementById('awarded-points-text'),
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
summaryScore: document.getElementById('summary-score'),
|
||||||
|
summaryPercentage: document.getElementById('summary-percentage'),
|
||||||
|
summaryStars: document.getElementById('summary-stars'),
|
||||||
|
summaryRank: document.getElementById('summary-rank'),
|
||||||
|
|
||||||
|
// Audio Modal
|
||||||
|
btnAudioSettings: document.getElementById('btn-audio-settings'),
|
||||||
|
btnRestart: document.getElementById('btn-restart'),
|
||||||
|
modalAudio: document.getElementById('modal-audio'),
|
||||||
|
btnCloseAudioModal: document.getElementById('btn-close-audio-modal'),
|
||||||
|
btnSaveAudio: document.getElementById('btn-save-audio'),
|
||||||
|
toggleMasterMute: document.getElementById('toggle-master-mute'),
|
||||||
|
sliderMusicVol: document.getElementById('slider-music-vol'),
|
||||||
|
sliderSfxVol: document.getElementById('slider-sfx-vol'),
|
||||||
|
toggleAmbientHum: document.getElementById('toggle-ambient-hum'),
|
||||||
|
musicVolNum: document.getElementById('music-vol-num'),
|
||||||
|
sfxVolNum: document.getElementById('sfx-vol-num')
|
||||||
|
};
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.avatar = new WorkerAvatar('avatar-container');
|
||||||
|
this.particles = new ParticleEngine('stage-fx-canvas');
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
this.updateHUD();
|
||||||
|
|
||||||
|
// Listen for freeze frame event from particle collision
|
||||||
|
window.addEventListener('hazard-freeze-triggered', (e) => {
|
||||||
|
this.handleFreezeFrameAlert(e.detail.projectile);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Start Game
|
||||||
|
this.dom.btnStartGame.addEventListener('click', () => {
|
||||||
|
window.soundEngine.ensureContext();
|
||||||
|
window.soundEngine.playStartIntroSting();
|
||||||
|
this.switchScreen('game');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.soundEngine.startBackgroundMusic();
|
||||||
|
this.loadScenario(0);
|
||||||
|
}, 900);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Worker Safety
|
||||||
|
this.dom.btnTestSafety.addEventListener('click', () => {
|
||||||
|
this.executeSafetyTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear PPE
|
||||||
|
this.dom.btnClearPpe.addEventListener('click', () => {
|
||||||
|
window.soundEngine.playDeselectSFX();
|
||||||
|
this.equippedItems.clear();
|
||||||
|
this.avatar.setEquipped([]);
|
||||||
|
this.renderLockerItems();
|
||||||
|
this.updateCoverageIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix PPE (From Consequence Modal)
|
||||||
|
this.dom.btnFixPpe.addEventListener('click', () => {
|
||||||
|
window.soundEngine.playButtonClick();
|
||||||
|
this.dismissConsequenceModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Next Scenario
|
||||||
|
this.dom.btnNextScenario.addEventListener('click', () => {
|
||||||
|
window.soundEngine.playButtonClick();
|
||||||
|
this.dom.successOverlay.classList.remove('active');
|
||||||
|
this.particles.stopMode();
|
||||||
|
|
||||||
|
if (this.currentScenarioIndex < SCENARIO_DATA.length - 1) {
|
||||||
|
this.loadScenario(this.currentScenarioIndex + 1);
|
||||||
|
} else {
|
||||||
|
this.showFinalSummary();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replay Challenge
|
||||||
|
this.dom.btnReplay.addEventListener('click', () => {
|
||||||
|
window.soundEngine.playButtonClick();
|
||||||
|
this.score = 0;
|
||||||
|
this.scenarioCompleted = [false, false];
|
||||||
|
this.switchScreen('game');
|
||||||
|
window.soundEngine.startBackgroundMusic();
|
||||||
|
this.loadScenario(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print Certificate
|
||||||
|
this.dom.btnPrintCertificate.addEventListener('click', () => {
|
||||||
|
const dateEl = document.getElementById('cert-date-val');
|
||||||
|
if (dateEl) {
|
||||||
|
dateEl.textContent = new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart Button (Top Bar)
|
||||||
|
this.dom.btnRestart.addEventListener('click', () => {
|
||||||
|
if (confirm("Restart the PPE Safety Challenge from the beginning?")) {
|
||||||
|
window.soundEngine.playButtonClick();
|
||||||
|
this.score = 0;
|
||||||
|
this.scenarioCompleted = [false, false];
|
||||||
|
this.switchScreen('start');
|
||||||
|
window.soundEngine.stopBackgroundMusic();
|
||||||
|
window.soundEngine.stopAmbient();
|
||||||
|
window.soundEngine.stopGrinderSFX();
|
||||||
|
window.soundEngine.stopLiquidTransferSFX();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio Modal Controls
|
||||||
|
this.dom.btnAudioSettings.addEventListener('click', () => {
|
||||||
|
this.dom.modalAudio.classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.btnCloseAudioModal.addEventListener('click', () => {
|
||||||
|
this.dom.modalAudio.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.btnSaveAudio.addEventListener('click', () => {
|
||||||
|
this.dom.modalAudio.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.toggleMasterMute.addEventListener('change', (e) => {
|
||||||
|
window.soundEngine.setMasterMute(!e.target.checked);
|
||||||
|
document.getElementById('audio-icon').textContent = e.target.checked ? '🔊' : '🔇';
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.sliderMusicVol.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value) / 100;
|
||||||
|
this.dom.musicVolNum.textContent = `${e.target.value}%`;
|
||||||
|
window.soundEngine.setMusicVolume(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.sliderSfxVol.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value) / 100;
|
||||||
|
this.dom.sfxVolNum.textContent = `${e.target.value}%`;
|
||||||
|
window.soundEngine.setSfxVolume(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.toggleAmbientHum.addEventListener('change', (e) => {
|
||||||
|
window.soundEngine.setAmbientEnabled(e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard Shortcuts
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
// Numbers 1-9 to toggle PPE
|
||||||
|
const num = parseInt(e.key);
|
||||||
|
if (!isNaN(num) && num >= 1 && num <= 9) {
|
||||||
|
const cards = document.querySelectorAll('.ppe-item-card');
|
||||||
|
if (cards[num - 1]) {
|
||||||
|
cards[num - 1].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacebar to Test Safety
|
||||||
|
if (e.code === 'Space' && this.dom.screenGame.classList.contains('active')) {
|
||||||
|
if (!this.dom.consequenceOverlay.classList.contains('active') && !this.dom.successOverlay.classList.contains('active')) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.executeSafetyTest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esc to close overlays/modals
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (this.dom.modalAudio.classList.contains('active')) {
|
||||||
|
this.dom.modalAudio.classList.remove('active');
|
||||||
|
} else if (this.dom.consequenceOverlay.classList.contains('active')) {
|
||||||
|
this.dismissConsequenceModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'M' to toggle Audio Settings
|
||||||
|
if (e.key === 'm' || e.key === 'M') {
|
||||||
|
this.dom.modalAudio.classList.toggle('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switchScreen(screenName) {
|
||||||
|
this.dom.screenStart.classList.remove('active');
|
||||||
|
this.dom.screenGame.classList.remove('active');
|
||||||
|
this.dom.screenSummary.classList.remove('active');
|
||||||
|
|
||||||
|
if (screenName === 'start') this.dom.screenStart.classList.add('active');
|
||||||
|
if (screenName === 'game') this.dom.screenGame.classList.add('active');
|
||||||
|
if (screenName === 'summary') this.dom.screenSummary.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadScenario(index) {
|
||||||
|
this.currentScenarioIndex = index;
|
||||||
|
this.currentScenario = SCENARIO_DATA[index];
|
||||||
|
this.scenarioFirstAttempt = true;
|
||||||
|
this.equippedItems.clear();
|
||||||
|
this.avatar.setEquipped([]);
|
||||||
|
this.particles.clear();
|
||||||
|
this.particles.stopMode();
|
||||||
|
|
||||||
|
// Update Stepper HUD
|
||||||
|
this.dom.stepPill1.className = index === 0 ? 'step-pill active' : (this.scenarioCompleted[0] ? 'step-pill completed' : 'step-pill');
|
||||||
|
this.dom.stepPill2.className = index === 1 ? 'step-pill active' : (this.scenarioCompleted[1] ? 'step-pill completed' : 'step-pill');
|
||||||
|
|
||||||
|
// Scene Styling
|
||||||
|
this.dom.sceneStage.className = `scene-environment-container ${this.currentScenario.sceneClass}`;
|
||||||
|
this.dom.sceneBadge.textContent = `SCENARIO ${index + 1} OF ${SCENARIO_DATA.length}`;
|
||||||
|
this.dom.sceneTitle.textContent = this.currentScenario.title;
|
||||||
|
this.dom.sceneDesc.textContent = this.currentScenario.environmentDesc;
|
||||||
|
this.dom.lockerGuideText.textContent = this.currentScenario.guideTip;
|
||||||
|
this.dom.audioCaption.textContent = `[Audio: ${this.currentScenario.audioCaption}]`;
|
||||||
|
|
||||||
|
// Status Bar
|
||||||
|
this.dom.statusLight.className = 'status-indicator-light';
|
||||||
|
this.dom.statusText.textContent = `TASK IDLE - EQUIP WORKER BEFORE STARTING ${this.currentScenario.taskName}`;
|
||||||
|
|
||||||
|
// Populate Hazard List
|
||||||
|
this.dom.hazardList.innerHTML = this.currentScenario.hazards.map(h => `
|
||||||
|
<li class="hazard-list-item">
|
||||||
|
<span>${h.label}</span>
|
||||||
|
</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Populate Locker Grid
|
||||||
|
this.renderLockerItems();
|
||||||
|
this.updateCoverageIndicators();
|
||||||
|
|
||||||
|
// Start Ambient Sound for this scenario
|
||||||
|
window.soundEngine.setAmbientScenario(this.currentScenario.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLockerItems() {
|
||||||
|
this.dom.lockerGrid.innerHTML = this.currentScenario.lockerItems.map((item, idx) => {
|
||||||
|
const isEquipped = this.equippedItems.has(item.id);
|
||||||
|
return `
|
||||||
|
<div class="ppe-item-card ${isEquipped ? 'equipped' : ''}" data-item-id="${item.id}" data-slot="${item.slot}" title="${item.spec}">
|
||||||
|
<span class="item-key-badge">${idx + 1}</span>
|
||||||
|
<div class="item-toggle-indicator">${isEquipped ? '✓' : ''}</div>
|
||||||
|
<div class="item-icon-box">${item.icon}</div>
|
||||||
|
<span class="item-name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind click events on cards
|
||||||
|
this.dom.lockerGrid.querySelectorAll('.ppe-item-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const itemId = card.dataset.itemId;
|
||||||
|
const slot = card.dataset.slot;
|
||||||
|
this.toggleItem(itemId, slot);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleItem(itemId, slot) {
|
||||||
|
if (this.equippedItems.has(itemId)) {
|
||||||
|
// Unequip
|
||||||
|
this.equippedItems.delete(itemId);
|
||||||
|
this.avatar.unequip(itemId);
|
||||||
|
window.soundEngine.playDeselectSFX();
|
||||||
|
} else {
|
||||||
|
// If equipping, replace any existing item occupying the same slot
|
||||||
|
const existingInSlot = this.currentScenario.lockerItems.find(
|
||||||
|
it => it.slot === slot && this.equippedItems.has(it.id)
|
||||||
|
);
|
||||||
|
if (existingInSlot) {
|
||||||
|
this.equippedItems.delete(existingInSlot.id);
|
||||||
|
this.avatar.unequip(existingInSlot.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.equippedItems.add(itemId);
|
||||||
|
this.avatar.equip(itemId);
|
||||||
|
window.soundEngine.playEquipSFX();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderLockerItems();
|
||||||
|
this.updateCoverageIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCoverageIndicators() {
|
||||||
|
const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item');
|
||||||
|
coverageItems.forEach(itemEl => {
|
||||||
|
const slot = itemEl.dataset.slot;
|
||||||
|
const statusEl = itemEl.querySelector('.cov-status');
|
||||||
|
|
||||||
|
// Find if any item is equipped in this slot
|
||||||
|
const isSlotEquipped = Array.from(this.equippedItems).some(itemId => {
|
||||||
|
const itemObj = this.currentScenario.lockerItems.find(it => it.id === itemId);
|
||||||
|
return itemObj && itemObj.slot === slot;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSlotEquipped) {
|
||||||
|
statusEl.className = 'cov-status protected';
|
||||||
|
statusEl.textContent = 'Equipped';
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'cov-status missing';
|
||||||
|
statusEl.textContent = 'Missing';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SAFETY CHECK EXECUTION & CONSEQUENCE DETECTION
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
executeSafetyTest() {
|
||||||
|
window.soundEngine.playButtonClick();
|
||||||
|
|
||||||
|
// 1. Identify ALL equipped distractors / incorrect items
|
||||||
|
const allEquippedDistractors = this.currentScenario.lockerItems.filter(
|
||||||
|
it => this.equippedItems.has(it.id) && !it.isCorrect
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Identify ALL missing required critical gear checks
|
||||||
|
const allMissingChecks = this.currentScenario.criticalChecks.filter(
|
||||||
|
chk => !this.equippedItems.has(chk.requiredItem)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSafe = allEquippedDistractors.length === 0 && allMissingChecks.length === 0;
|
||||||
|
|
||||||
|
if (isSafe) {
|
||||||
|
// 100% SAFE SUCCESS PATH
|
||||||
|
this.handleSafeSuccess();
|
||||||
|
} else {
|
||||||
|
// UNSAFE FAILURE / CONSEQUENCE NEAR-MISS PATH (Collect all issues)
|
||||||
|
this.handleUnsafeConsequence(allMissingChecks, allEquippedDistractors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSafeSuccess() {
|
||||||
|
// 1. Update visual state
|
||||||
|
this.dom.statusLight.className = 'status-indicator-light running';
|
||||||
|
this.dom.statusText.textContent = `OPERATION RUNNING SAFELY - ALL HAZARDS MITIGATED`;
|
||||||
|
|
||||||
|
// 2. Start Task Simulation SFX & Particles
|
||||||
|
if (this.currentScenario.id === 'grinding') {
|
||||||
|
window.soundEngine.startGrinderSFX();
|
||||||
|
this.particles.startMode('sparks');
|
||||||
|
} else {
|
||||||
|
window.soundEngine.startLiquidTransferSFX();
|
||||||
|
this.particles.startMode('chemical');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Play Success Chime & Star sound after short operational run
|
||||||
|
setTimeout(() => {
|
||||||
|
window.soundEngine.playSuccessChime();
|
||||||
|
window.soundEngine.playSafetyStarSound();
|
||||||
|
|
||||||
|
// Award points (100 on first try, 80 on retry)
|
||||||
|
const points = this.scenarioFirstAttempt ? 100 : 80;
|
||||||
|
this.score += points;
|
||||||
|
this.scenarioCompleted[this.currentScenarioIndex] = true;
|
||||||
|
this.updateHUD();
|
||||||
|
|
||||||
|
// Show Success Modal Overlay
|
||||||
|
this.dom.successTitle.textContent = this.currentScenario.successMessage.title;
|
||||||
|
this.dom.successDesc.textContent = this.currentScenario.successMessage.body;
|
||||||
|
this.dom.awardedPointsText.textContent = `+${points} Safety Points ⭐`;
|
||||||
|
|
||||||
|
if (this.currentScenarioIndex === SCENARIO_DATA.length - 1) {
|
||||||
|
this.dom.btnNextScenarioText.textContent = 'VIEW FINAL RESULTS ➔';
|
||||||
|
} else {
|
||||||
|
this.dom.btnNextScenarioText.textContent = 'NEXT SCENARIO ➔';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dom.successOverlay.classList.add('active');
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUnsafeConsequence(allMissingChecks, allEquippedDistractors) {
|
||||||
|
this.scenarioFirstAttempt = false;
|
||||||
|
|
||||||
|
const totalIssues = allMissingChecks.length + allEquippedDistractors.length;
|
||||||
|
|
||||||
|
// 1. Start task machine momentarily
|
||||||
|
this.dom.statusLight.className = 'status-indicator-light danger';
|
||||||
|
this.dom.statusText.textContent = `CRITICAL HAZARD DETECTED (${totalIssues} ISSUES) - FREEZING OPERATION!`;
|
||||||
|
|
||||||
|
if (this.currentScenario.id === 'grinding') {
|
||||||
|
window.soundEngine.startGrinderSFX();
|
||||||
|
this.particles.startMode('sparks');
|
||||||
|
} else {
|
||||||
|
window.soundEngine.startLiquidTransferSFX();
|
||||||
|
this.particles.startMode('chemical');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine target position for incoming projectile (prioritize eye/face if missing, otherwise hand/body)
|
||||||
|
const avatarCoords = this.avatar.getAvatarCenterCoordinates();
|
||||||
|
const stageWidth = this.dom.sceneStage.clientWidth;
|
||||||
|
const stageHeight = this.dom.sceneStage.clientHeight;
|
||||||
|
const originX = stageWidth * 0.72;
|
||||||
|
const originY = stageHeight * 0.65;
|
||||||
|
|
||||||
|
const hasEyeFaceIssue = allMissingChecks.some(chk => chk.slot === 'eyes' || chk.slot === 'head');
|
||||||
|
const targetX = hasEyeFaceIssue ? avatarCoords.eyeX : avatarCoords.handX;
|
||||||
|
const targetY = hasEyeFaceIssue ? avatarCoords.eyeY : avatarCoords.handY;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.soundEngine.playWhooshImpact();
|
||||||
|
this.particles.spawnHazardProjectile(
|
||||||
|
this.currentScenario.id === 'grinding' ? 'spark' : 'chemical_drop',
|
||||||
|
originX, originY, targetX, targetY
|
||||||
|
);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
// Store consequence context for freeze overlay
|
||||||
|
this.activeConsequenceData = { allMissingChecks, allEquippedDistractors };
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFreezeFrameAlert(projectile) {
|
||||||
|
// 1. Freeze particle engine & stop machine motors
|
||||||
|
this.particles.setFrozen(true);
|
||||||
|
window.soundEngine.stopGrinderSFX();
|
||||||
|
window.soundEngine.stopLiquidTransferSFX();
|
||||||
|
|
||||||
|
// 2. Duck background music
|
||||||
|
window.soundEngine.duckMusic(true);
|
||||||
|
|
||||||
|
// 3. Play warning tones
|
||||||
|
window.soundEngine.playWarningTone();
|
||||||
|
window.soundEngine.playCriticalStopAlert();
|
||||||
|
|
||||||
|
const { allMissingChecks, allEquippedDistractors } = this.activeConsequenceData;
|
||||||
|
const totalIssues = allMissingChecks.length + allEquippedDistractors.length;
|
||||||
|
|
||||||
|
// Slot icons mapping
|
||||||
|
const slotIcons = {
|
||||||
|
head: '🧢',
|
||||||
|
eyes: '👓',
|
||||||
|
ears: '🎧',
|
||||||
|
hands: '🧤',
|
||||||
|
body: '🦺',
|
||||||
|
feet: '🥾'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Update Header & Badges
|
||||||
|
this.dom.hazardFlasherBadge.textContent = `⚠️ STOP! ${totalIssues} SAFETY ${totalIssues === 1 ? 'HAZARD' : 'HAZARDS'} DETECTED`;
|
||||||
|
this.dom.missingCountNum.textContent = totalIssues;
|
||||||
|
|
||||||
|
if (totalIssues === 1) {
|
||||||
|
if (allEquippedDistractors.length > 0) {
|
||||||
|
const d = allEquippedDistractors[0];
|
||||||
|
this.dom.consequenceTitle.textContent = "Incorrect / Ineffective PPE Detected";
|
||||||
|
this.dom.consequenceDesc.textContent = d.rejectReason;
|
||||||
|
} else {
|
||||||
|
const m = allMissingChecks[0];
|
||||||
|
this.dom.consequenceTitle.textContent = m.consequenceTitle;
|
||||||
|
this.dom.consequenceDesc.textContent = m.consequenceDesc;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.dom.consequenceTitle.textContent = "Multiple Critical Protection Gaps Detected";
|
||||||
|
this.dom.consequenceDesc.textContent = "Operating without complete PPE exposes multiple body zones to direct, severe workplace injuries. All required safety equipment must be equipped before starting.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build HTML for ALL Missing & Incorrect Items Simultaneously
|
||||||
|
let cardsHtml = '';
|
||||||
|
|
||||||
|
// Render all missing critical checks
|
||||||
|
allMissingChecks.forEach(chk => {
|
||||||
|
const icon = slotIcons[chk.slot] || '⚠️';
|
||||||
|
cardsHtml += `
|
||||||
|
<div class="hazard-card-item">
|
||||||
|
<div class="card-icon">${icon}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-header-line">
|
||||||
|
<span class="card-item-name">${chk.missingLabel}</span>
|
||||||
|
<span class="card-slot-badge">${chk.slot} - Missing</span>
|
||||||
|
</div>
|
||||||
|
<span class="card-hazard-text">${chk.consequenceDesc}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render all equipped distractors that need to be removed
|
||||||
|
allEquippedDistractors.forEach(dist => {
|
||||||
|
cardsHtml += `
|
||||||
|
<div class="hazard-card-item distractor-card">
|
||||||
|
<div class="card-icon">🚫</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-header-line">
|
||||||
|
<span class="card-item-name">REMOVE: ${dist.name}</span>
|
||||||
|
<span class="card-slot-badge">Unsafe Gear</span>
|
||||||
|
</div>
|
||||||
|
<span class="card-hazard-text">${dist.rejectReason}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dom.consequenceHazardList.innerHTML = cardsHtml;
|
||||||
|
|
||||||
|
// 6. Highlight missing slots in Coverage Grid with visual alert animation
|
||||||
|
const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item');
|
||||||
|
coverageItems.forEach(itemEl => {
|
||||||
|
const slot = itemEl.dataset.slot;
|
||||||
|
const isSlotMissing = allMissingChecks.some(chk => chk.slot === slot);
|
||||||
|
if (isSlotMissing) {
|
||||||
|
itemEl.classList.add('alert-missing');
|
||||||
|
} else {
|
||||||
|
itemEl.classList.remove('alert-missing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Display freeze overlay
|
||||||
|
this.dom.consequenceOverlay.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissConsequenceModal() {
|
||||||
|
// Unfreeze and restore normal background music
|
||||||
|
this.dom.consequenceOverlay.classList.remove('active');
|
||||||
|
this.particles.clear();
|
||||||
|
this.particles.setFrozen(false);
|
||||||
|
this.particles.stopMode();
|
||||||
|
window.soundEngine.duckMusic(false);
|
||||||
|
|
||||||
|
// Clear alert classes on coverage items
|
||||||
|
const coverageItems = this.dom.coverageGrid.querySelectorAll('.coverage-item');
|
||||||
|
coverageItems.forEach(itemEl => itemEl.classList.remove('alert-missing'));
|
||||||
|
|
||||||
|
this.dom.statusLight.className = 'status-indicator-light';
|
||||||
|
this.dom.statusText.textContent = `TASK IDLE - ADJUST PPE SELECTION`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// SUMMARY & COMPLETION SCREEN
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
showFinalSummary() {
|
||||||
|
this.switchScreen('summary');
|
||||||
|
window.soundEngine.playVictorySting();
|
||||||
|
this.particles.spawnConfetti();
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
const totalMax = SCENARIO_DATA.length * 100;
|
||||||
|
const pct = Math.round((this.score / totalMax) * 100);
|
||||||
|
this.dom.summaryScore.textContent = `${this.score} / ${totalMax}`;
|
||||||
|
this.dom.summaryPercentage.textContent = `${pct}% Safety Compliance Rating`;
|
||||||
|
|
||||||
|
if (this.score >= 180) {
|
||||||
|
this.dom.summaryStars.textContent = "⭐⭐";
|
||||||
|
this.dom.summaryRank.textContent = "Master Safety Practitioner (Zero Accidents)";
|
||||||
|
} else {
|
||||||
|
this.dom.summaryStars.textContent = "⭐";
|
||||||
|
this.dom.summaryRank.textContent = "Safety Certified (Refresher Recommended)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHUD() {
|
||||||
|
this.dom.hudScore.textContent = this.score;
|
||||||
|
const starCount = Math.floor(this.score / 100);
|
||||||
|
this.dom.hudStars.textContent = "⭐".repeat(starCount) + "☆".repeat(Math.max(0, 2 - starCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate game on load
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.safetyGame = new SafetyGame();
|
||||||
|
});
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+373
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* PPE SAFETY CHALLENGE - SCENARIO DEFINITIONS & SAFETY DATABASE
|
||||||
|
* Implements: SEE THE SITUATION -> IDENTIFY HAZARDS -> SELECT PPE -> CONSEQUENCES -> CORRECT
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCENARIO_DATA = [
|
||||||
|
{
|
||||||
|
id: "grinding",
|
||||||
|
index: 1,
|
||||||
|
title: "Metal Grinding Operation",
|
||||||
|
taskName: "ANGLE GRINDER WELD SMOOTHING",
|
||||||
|
sceneClass: "stage-grinding",
|
||||||
|
environmentDesc: "An industrial worker is preparing to grind rough weld seams on heavy structural steel using a high-speed angle grinder (11,000 RPM).",
|
||||||
|
audioCaption: "Workshop Ambience: Machinery hum & distant fabrication noise",
|
||||||
|
guideTip: "High-speed spinning discs eject fiery sparks and shrapnel at over 100 m/s with extreme noise (95+ dB).",
|
||||||
|
|
||||||
|
hazards: [
|
||||||
|
{
|
||||||
|
id: "flying_sparks",
|
||||||
|
label: "Flying hot metal sparks & abrasive wheel particles (11,000 RPM)",
|
||||||
|
criticalSlot: "eyes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high_noise",
|
||||||
|
label: "Continuous high decibel motor and grinding noise (95-105 dB)",
|
||||||
|
criticalSlot: "ears"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sharp_burrs",
|
||||||
|
label: "Sharp metal burrs, intense friction heat & wheel kickback hazard",
|
||||||
|
criticalSlot: "hands"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "falling_debris",
|
||||||
|
label: "Heavy steel drop hazard & structural workshop overhead risks",
|
||||||
|
criticalSlot: "feet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Required protective gear items
|
||||||
|
requiredPpe: [
|
||||||
|
"faceshield_glasses",
|
||||||
|
"leather_grinding_gloves",
|
||||||
|
"earmuffs_heavy",
|
||||||
|
"hard_hat_cap",
|
||||||
|
"heavy_leather_jacket",
|
||||||
|
"steel_toe_boots"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Critical slots for safety check evaluation
|
||||||
|
criticalChecks: [
|
||||||
|
{
|
||||||
|
slot: "eyes",
|
||||||
|
requiredItem: "faceshield_glasses",
|
||||||
|
consequenceTitle: "Flying Particle Hazard",
|
||||||
|
consequenceDesc: "Without full face & eye impact protection, flying abrasive particles at 11,000 RPM can cause catastrophic corneal penetration or severe facial lacerations.",
|
||||||
|
missingLabel: "Full Face Shield + Safety Glasses (ANSI Z87+)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "ears",
|
||||||
|
requiredItem: "earmuffs_heavy",
|
||||||
|
consequenceTitle: "Noise-Induced Hearing Damage",
|
||||||
|
consequenceDesc: "Angle grinding routinely generates 100+ dB. Operating without rated hearing protection causes irreversible inner-ear acoustic trauma.",
|
||||||
|
missingLabel: "Heavy Duty Hearing Protection (Earmuffs / Plugs)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "hands",
|
||||||
|
requiredItem: "leather_grinding_gloves",
|
||||||
|
consequenceTitle: "Thermal Burn & Abrasion Danger",
|
||||||
|
consequenceDesc: "Thin latex or bare hands offer zero protection against glowing 800°C sparks and jagged steel edges. Heavy-duty leather gloves are required.",
|
||||||
|
missingLabel: "Heavy-Duty Split Cowhide Leather Grinding Gloves"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "feet",
|
||||||
|
requiredItem: "steel_toe_boots",
|
||||||
|
consequenceTitle: "Crush & Penetration Hazard",
|
||||||
|
consequenceDesc: "Standard footwear cannot protect against dropped structural steel sections or sharp discarded metal shards on workshop floors.",
|
||||||
|
missingLabel: "Steel-Toe Safety Boots with Puncture Resistant Sole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "head",
|
||||||
|
requiredItem: "hard_hat_cap",
|
||||||
|
consequenceTitle: "Overhead Impact & Debris Risk",
|
||||||
|
consequenceDesc: "In fabrication bays, overhead cranes and moving metal fixtures require impact-certified head protection.",
|
||||||
|
missingLabel: "Industrial Hard Hat (Type 1, Class E)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "body",
|
||||||
|
requiredItem: "heavy_leather_jacket",
|
||||||
|
consequenceTitle: "Clothing Ignition / Hot Spark Burn",
|
||||||
|
consequenceDesc: "Synthetic regular clothing easily melts or ignites from dense grinder spark streams. Heavy flame-resistant leather/cotton protective wear is essential.",
|
||||||
|
missingLabel: "Flame-Resistant Heavy Leather Welder/Grinder Jacket"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Locker items available for selection (including distractors)
|
||||||
|
lockerItems: [
|
||||||
|
{
|
||||||
|
id: "faceshield_glasses",
|
||||||
|
name: "Face Shield + Safety Glasses",
|
||||||
|
slot: "eyes",
|
||||||
|
icon: "🛡️",
|
||||||
|
spec: "ANSI Z87+ Impact Dual Protection",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regular_sunglasses",
|
||||||
|
name: "Standard Tinted Sunglasses",
|
||||||
|
slot: "eyes",
|
||||||
|
icon: "🕶️",
|
||||||
|
spec: "Non-impact rated tinted plastic",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Standard sunglasses shatter upon impact and leave the face exposed to flying debris."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "leather_grinding_gloves",
|
||||||
|
name: "Leather Grinding Gloves",
|
||||||
|
slot: "hands",
|
||||||
|
icon: "🧤",
|
||||||
|
spec: "Heavy split cowhide heat/spark resistant",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thin_latex_gloves",
|
||||||
|
name: "Thin Disposable Latex Gloves",
|
||||||
|
slot: "hands",
|
||||||
|
icon: "🧤",
|
||||||
|
spec: "Medical grade 4mil rubber (Flaggable)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Thin latex immediately melts onto skin when hit by 800°C grinder sparks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "earmuffs_heavy",
|
||||||
|
name: "Industrial Ear Muffs (NRR 30)",
|
||||||
|
slot: "ears",
|
||||||
|
icon: "🎧",
|
||||||
|
spec: "High noise reduction acoustic seal",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wireless_earbuds",
|
||||||
|
name: "Consumer Music Earbuds",
|
||||||
|
slot: "ears",
|
||||||
|
icon: "🎵",
|
||||||
|
spec: "Audio playback (Non-certified)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Music earphones are not certified PPE and distract the operator from machine warnings."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hard_hat_cap",
|
||||||
|
name: "Industrial Hard Hat",
|
||||||
|
slot: "head",
|
||||||
|
icon: "⛑️",
|
||||||
|
spec: "ANSI Z89.1 Type 1 Class E",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "heavy_leather_jacket",
|
||||||
|
name: "Flame-Resistant Work Jacket",
|
||||||
|
slot: "body",
|
||||||
|
icon: "🦺",
|
||||||
|
spec: "Heavy-duty spark-resistant leather/cotton",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "steel_toe_boots",
|
||||||
|
name: "Steel-Toe Safety Boots",
|
||||||
|
slot: "feet",
|
||||||
|
icon: "🥾",
|
||||||
|
spec: "ASTM F2413 Impact & Puncture Rated",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "regular_sneakers",
|
||||||
|
name: "Canvas Running Sneakers",
|
||||||
|
slot: "feet",
|
||||||
|
icon: "👟",
|
||||||
|
spec: "Fabric sports shoe (Zero drop rating)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Fabric sneakers ignite from sparks and offer zero crush protection from dropped metal."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
successMessage: {
|
||||||
|
title: "SAFE TO START",
|
||||||
|
headline: "Correct PPE selected.",
|
||||||
|
body: "Your eyes, face, hands, hearing and feet are protected from the main hazards of this task."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: "chemical",
|
||||||
|
index: 2,
|
||||||
|
title: "Chemical Handling & Dispensing",
|
||||||
|
taskName: "CORROSIVE SOLVENT TRANSFER",
|
||||||
|
sceneClass: "stage-chemical",
|
||||||
|
environmentDesc: "An employee is preparing to transfer an industrial corrosive solvent between approved containment drums inside a ventilated chemical dispensing station.",
|
||||||
|
audioCaption: "Ventilation Ambience: Fume extraction hood airflow & quiet laboratory background",
|
||||||
|
guideTip: "Corrosive chemicals cause irreversible chemical burns upon skin contact and severe vapor irritation.",
|
||||||
|
|
||||||
|
hazards: [
|
||||||
|
{
|
||||||
|
id: "chemical_splash",
|
||||||
|
label: "Corrosive liquid splash risk during pouring & transfer",
|
||||||
|
criticalSlot: "eyes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "skin_absorption",
|
||||||
|
label: "Acute dermal chemical burn and toxic absorption",
|
||||||
|
criticalSlot: "hands"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "body_soak",
|
||||||
|
label: "Direct container overflow or accidental splash onto torso & legs",
|
||||||
|
criticalSlot: "body"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foot_contact",
|
||||||
|
label: "Chemical pooling on floor and footwear degradation",
|
||||||
|
criticalSlot: "feet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Required protective gear items
|
||||||
|
requiredPpe: [
|
||||||
|
"chemical_splash_goggles",
|
||||||
|
"nitrile_gauntlet_gloves",
|
||||||
|
"chemical_resistant_apron",
|
||||||
|
"chemical_rubber_boots",
|
||||||
|
"hard_hat_chemical",
|
||||||
|
"organic_vapor_respirator"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Critical slots evaluation
|
||||||
|
criticalChecks: [
|
||||||
|
{
|
||||||
|
slot: "eyes",
|
||||||
|
requiredItem: "chemical_splash_goggles",
|
||||||
|
consequenceTitle: "Chemical Splash to Eyes",
|
||||||
|
consequenceDesc: "Standard safety glasses with open side vents allow hazardous liquid droplets to roll directly into the eyes, causing permanent corneal scarring.",
|
||||||
|
missingLabel: "Indirectly Vented Chemical Splash Goggles (ANSI Z87.1 D3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "hands",
|
||||||
|
requiredItem: "nitrile_gauntlet_gloves",
|
||||||
|
consequenceTitle: "Dermal Chemical Permeation",
|
||||||
|
consequenceDesc: "Porous leather or basic household gloves rapidly absorb harsh solvents, causing chemical burns and systemic toxicity through the skin.",
|
||||||
|
missingLabel: "Heavy-Duty Chemical-Resistant Nitrile/Neoprene Gauntlets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "body",
|
||||||
|
requiredItem: "chemical_resistant_apron",
|
||||||
|
consequenceTitle: "Body Splash & Acid Soak Hazard",
|
||||||
|
consequenceDesc: "Standard porous fabric clothing absorbs chemical splashes immediately, holding corrosive liquids against the skin.",
|
||||||
|
missingLabel: "Chemical-Resistant Impervious PVC/Neoprene Apron"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "feet",
|
||||||
|
requiredItem: "chemical_rubber_boots",
|
||||||
|
consequenceTitle: "Corrosive Liquid Footwear Infiltration",
|
||||||
|
consequenceDesc: "Standard leather safety boots dissolve and degrade when exposed to pooled industrial acids and solvents.",
|
||||||
|
missingLabel: "Chemical-Resistant Neoprene/Rubber Boots"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "ears",
|
||||||
|
requiredItem: "organic_vapor_respirator", // Uses respiratory/head slot
|
||||||
|
consequenceTitle: "Hazardous Vapor Inhalation",
|
||||||
|
consequenceDesc: "Concentrated chemical vapors overcome unprotected workers quickly. Half-mask respirator with chemical cartridges is required.",
|
||||||
|
missingLabel: "Half-Mask Respirator with Organic Vapor/Acid Gas Cartridges"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slot: "head",
|
||||||
|
requiredItem: "hard_hat_chemical",
|
||||||
|
consequenceTitle: "Overhead Chemical Piping Hazard",
|
||||||
|
consequenceDesc: "Chemical distribution zones require head protection against overhead pipe drips and valve maintenance risks.",
|
||||||
|
missingLabel: "Chemical-Resistant Industrial Hard Hat"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
lockerItems: [
|
||||||
|
{
|
||||||
|
id: "chemical_splash_goggles",
|
||||||
|
name: "Chemical Splash Goggles",
|
||||||
|
slot: "eyes",
|
||||||
|
icon: "🥽",
|
||||||
|
spec: "ANSI Z87.1 D3 Sealed Splash Protection",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "standard_safety_glasses",
|
||||||
|
name: "Open Safety Glasses",
|
||||||
|
slot: "eyes",
|
||||||
|
icon: "👓",
|
||||||
|
spec: "Impact rated with open side vents (Non-liquid)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Open side vents allow liquid splashes and corrosive droplets to enter the eye."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nitrile_gauntlet_gloves",
|
||||||
|
name: "Nitrile Gauntlet Gloves",
|
||||||
|
slot: "hands",
|
||||||
|
icon: "🧤",
|
||||||
|
spec: "15-mil Heavy Nitrile Chemical Gauntlets",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "leather_work_gloves",
|
||||||
|
name: "Standard Leather Work Gloves",
|
||||||
|
slot: "hands",
|
||||||
|
icon: "🧤",
|
||||||
|
spec: "Porous split cowhide (Absorbent)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Leather absorbs corrosive chemicals like a sponge, causing deep chemical burns."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chemical_resistant_apron",
|
||||||
|
name: "Chemical-Resistant Apron",
|
||||||
|
slot: "body",
|
||||||
|
icon: "🦺",
|
||||||
|
spec: "Impermeable Heavy PVC / Neoprene Apron",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cotton_lab_coat",
|
||||||
|
name: "Standard Cotton Coat",
|
||||||
|
slot: "body",
|
||||||
|
icon: "🥼",
|
||||||
|
spec: "Porous woven cotton (Non-impervious)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Cotton absorbs acids and solvents directly into underclothes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chemical_rubber_boots",
|
||||||
|
name: "Chemical Rubber Boots",
|
||||||
|
slot: "feet",
|
||||||
|
icon: "🥾",
|
||||||
|
spec: "Seamless Neoprene Acid-Resistant Boots",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organic_vapor_respirator",
|
||||||
|
name: "Organic Vapor Respirator",
|
||||||
|
slot: "ears", // Grouped in face/respiratory slot
|
||||||
|
icon: "😷",
|
||||||
|
spec: "Half-Face Mask with Dual Acid/Vapor Cartridges",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hard_hat_chemical",
|
||||||
|
name: "Safety Hard Hat",
|
||||||
|
slot: "head",
|
||||||
|
icon: "⛑️",
|
||||||
|
spec: "Chemical-Resistant High Density Polyethylene",
|
||||||
|
isCorrect: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cloth_dust_mask",
|
||||||
|
name: "Simple Cloth Dust Mask",
|
||||||
|
slot: "ears",
|
||||||
|
icon: "😷",
|
||||||
|
spec: "Particulate only (No gas/vapor barrier)",
|
||||||
|
isCorrect: false,
|
||||||
|
rejectReason: "Cloth dust masks offer ZERO filtration against toxic chemical fumes and organic solvent vapors."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
successMessage: {
|
||||||
|
title: "SAFE TO PROCEED",
|
||||||
|
headline: "Correct chemical protection selected.",
|
||||||
|
body: "Your PPE helps protect the eyes, respiratory system and skin against accidental chemical splashes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user